@enyo-energy/sunspec-sdk 0.0.51 → 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,21 +20,75 @@ 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;
36
- // Serializes connection-state transitions so concurrent callers cannot open a second
37
- // TCP socket while the first is still alive (some Modbus devices allow only one client).
90
+ // Serializes connection-state transitions so concurrent callers cannot open duplicate
91
+ // sockets for the same unit ID while one is still alive.
38
92
  operationChain = Promise.resolve();
39
93
  constructor(energyApp) {
40
94
  this.energyApp = energyApp;
@@ -42,7 +96,10 @@ export class SunspecModbusClient {
42
96
  this.modbusDataTypeConverter = new EnergyAppModbusDataTypeConverter();
43
97
  }
44
98
  /**
45
- * 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.
46
103
  * @param host Primary host (hostname) to connect to
47
104
  * @param port Modbus port (default 502)
48
105
  * @param unitId Modbus unit ID (default 1)
@@ -50,45 +107,64 @@ export class SunspecModbusClient {
50
107
  */
51
108
  async connect(host, port = 502, unitId = 1, secondaryHost) {
52
109
  return this.withConnectionLock(async () => {
53
- const sameParams = this.connected
54
- && this.connectionParams !== null
55
- && this.connectionParams.primaryHost === host
56
- && this.connectionParams.port === port
57
- && this.connectionParams.unitId === unitId;
58
- if (sameParams) {
59
- console.debug(`connect(): already connected to ${host}:${port} unit ${unitId}, skipping`);
60
- return;
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
+ }
61
118
  }
62
- if (this.connected || this.modbusClient) {
63
- await this._closeSocket();
119
+ else {
120
+ this.connectionParams = { primaryHost: host, secondaryHost, port };
64
121
  }
65
- this.connectionParams = { primaryHost: host, secondaryHost, port, unitId };
66
- await this._openSocket(host, port, unitId);
67
- console.log(`Connected to Sunspec device at ${host}:${port} unit ${unitId} (opens=${this.openCount}, closes=${this.closeCount}, current=${this.currentlyOpen})`);
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})`);
68
129
  });
69
130
  }
70
131
  /**
71
- * Disconnect from Modbus device.
132
+ * Disconnect from all units of this network device.
72
133
  *
73
- * Note: connection parameters are preserved so reconnect() can be called afterwards.
74
- * 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.
75
136
  */
76
137
  async disconnect() {
77
138
  return this.withConnectionLock(async () => {
78
- if (!this.modbusClient && !this.connected) {
139
+ if (this.modbusInstances.size === 0) {
79
140
  return;
80
141
  }
81
142
  const host = this.connectionParams?.primaryHost ?? 'unknown';
82
143
  const port = this.connectionParams?.port ?? 0;
83
- const unitId = this.connectionParams?.unitId ?? 0;
84
- await this._closeSocket();
85
- console.log(`Disconnected from Sunspec device at ${host}:${port} unit ${unitId} (opens=${this.openCount}, closes=${this.closeCount}, current=${this.currentlyOpen})`);
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})`);
86
162
  });
87
163
  }
88
164
  /**
89
- * Reconnect using stored connection parameters
90
- * First tries primaryHost (hostname), then falls back to secondaryHost (ipAddress) if available
91
- * 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.
92
168
  */
93
169
  async reconnect() {
94
170
  return this.withConnectionLock(async () => {
@@ -96,18 +172,23 @@ export class SunspecModbusClient {
96
172
  console.error('Cannot reconnect: no connection parameters stored. Call connect() first.');
97
173
  return false;
98
174
  }
99
- const { primaryHost, secondaryHost, port, unitId } = this.connectionParams;
100
- console.log(`Attempting to reconnect to primary host ${primaryHost}:${port} unit ${unitId}...`);
101
- const primarySuccess = await this.attemptConnection(primaryHost, port, unitId);
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);
102
183
  if (primarySuccess) {
103
- console.log(`Successfully reconnected to primary host ${primaryHost}:${port} unit ${unitId}`);
184
+ console.log(`Successfully reconnected to primary host ${primaryHost}:${port} units [${units.join(',')}]`);
104
185
  return true;
105
186
  }
106
187
  if (secondaryHost && secondaryHost !== primaryHost) {
107
- console.log(`Primary host failed, attempting secondary host ${secondaryHost}:${port} unit ${unitId}...`);
108
- const secondarySuccess = await this.attemptConnection(secondaryHost, port, unitId);
188
+ console.log(`Primary host failed, attempting secondary host ${secondaryHost}:${port} units [${units.join(',')}]...`);
189
+ const secondarySuccess = await this.attemptConnection(secondaryHost, port, units);
109
190
  if (secondarySuccess) {
110
- console.log(`Successfully reconnected to secondary host ${secondaryHost}:${port} unit ${unitId}`);
191
+ console.log(`Successfully reconnected to secondary host ${secondaryHost}:${port} units [${units.join(',')}]`);
111
192
  return true;
112
193
  }
113
194
  }
@@ -116,16 +197,53 @@ export class SunspecModbusClient {
116
197
  });
117
198
  }
118
199
  /**
119
- * Attempt to establish a connection to a specific host. Caller must hold the connection lock.
120
- * Returns true if successful, false otherwise.
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`);
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.
121
235
  */
122
- async attemptConnection(host, port, unitId) {
123
- if (this.connected || this.modbusClient) {
124
- await this._closeSocket();
236
+ async attemptConnection(host, port, units) {
237
+ for (const unitId of units) {
238
+ if (this.modbusInstances.has(unitId)) {
239
+ await this._closeUnit(unitId);
240
+ }
125
241
  }
126
242
  try {
127
- await this._openSocket(host, port, unitId);
128
- console.log(`Connection attempt to ${host}:${port} unit ${unitId} succeeded (opens=${this.openCount}, closes=${this.closeCount}, current=${this.currentlyOpen})`);
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})`);
129
247
  return true;
130
248
  }
131
249
  catch (error) {
@@ -134,11 +252,11 @@ export class SunspecModbusClient {
134
252
  }
135
253
  }
136
254
  /**
137
- * Open a new Modbus socket and wire up the fault-tolerant reader. On any failure, the
138
- * just-opened socket (if any) is closed so we never leak. Caller must hold the connection
139
- * lock and ensure no prior socket is open (use _closeSocket first).
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.
140
258
  */
141
- async _openSocket(host, port, unitId) {
259
+ async _openUnit(host, port, unitId) {
142
260
  let candidate = null;
143
261
  try {
144
262
  candidate = await this.energyApp.useModbus().connect({
@@ -150,9 +268,9 @@ export class SunspecModbusClient {
150
268
  if (!candidate) {
151
269
  throw new Error(`useModbus().connect returned null for ${host}:${port} unit ${unitId}`);
152
270
  }
153
- this.faultTolerantReader = new EnergyAppModbusFaultTolerantReader(candidate, this.connectionHealth);
154
- this.modbusClient = candidate;
155
- this.connected = true;
271
+ const reader = new EnergyAppModbusFaultTolerantReader(candidate, this.connectionHealth);
272
+ this.modbusInstances.set(unitId, candidate);
273
+ this.faultTolerantReaders.set(unitId, reader);
156
274
  this.connectionHealth.recordSuccess();
157
275
  this.recordOpen();
158
276
  }
@@ -163,31 +281,28 @@ export class SunspecModbusClient {
163
281
  }
164
282
  catch { /* ignore */ }
165
283
  }
166
- this.modbusClient = null;
167
- this.faultTolerantReader = null;
168
- this.connected = false;
284
+ this.modbusInstances.delete(unitId);
285
+ this.faultTolerantReaders.delete(unitId);
169
286
  throw err;
170
287
  }
171
288
  }
172
289
  /**
173
- * Close the current Modbus socket if any. Idempotent. Caller must hold the connection lock.
290
+ * Close the Modbus instance for a single unit. Idempotent. Caller must hold the
291
+ * connection lock.
174
292
  */
175
- async _closeSocket() {
176
- if (!this.modbusClient && !this.connected)
177
- return;
178
- const client = this.modbusClient;
179
- const wasConnected = this.connected;
180
- this.modbusClient = null;
181
- this.faultTolerantReader = null;
182
- this.connected = false;
183
- this.discoveredModels.clear();
184
- if (client) {
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) {
185
300
  try {
186
- await client.disconnect();
301
+ await instance.disconnect();
187
302
  }
188
303
  catch { /* ignore */ }
189
304
  }
190
- if (wasConnected)
305
+ if (wasOpen)
191
306
  this.recordClose();
192
307
  }
193
308
  /**
@@ -201,27 +316,55 @@ export class SunspecModbusClient {
201
316
  }
202
317
  recordOpen() {
203
318
  this.openCount++;
204
- this.currentlyOpen++;
205
- if (this.currentlyOpen > 1) {
206
- console.error(`SunspecModbusClient: currentlyOpen=${this.currentlyOpen} after open ` +
207
- `(opens=${this.openCount}, closes=${this.closeCount}). ` +
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}. ` +
208
324
  `This indicates a code path bypassing the connection lock — please investigate.`);
209
325
  }
210
326
  }
211
327
  recordClose() {
212
328
  this.closeCount++;
213
- if (this.currentlyOpen > 0) {
214
- this.currentlyOpen--;
215
- }
216
- else {
217
- 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`);
218
331
  }
219
332
  }
220
333
  /**
221
- * 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.
222
336
  */
223
337
  getConnectionStats() {
224
- 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;
225
368
  }
226
369
  /**
227
370
  * Enable or disable automatic reconnection
@@ -239,16 +382,14 @@ export class SunspecModbusClient {
239
382
  /**
240
383
  * Detect the base address and addressing mode (0-based or 1-based) for SunSpec
241
384
  */
242
- async detectSunspecBaseAddress(customBaseAddress) {
243
- if (!this.modbusClient) {
244
- throw new Error('Modbus client not initialized');
245
- }
385
+ async detectSunspecBaseAddress(unitId, customBaseAddress) {
386
+ const instance = this.getInstance(unitId);
246
387
  // If custom base address provided, try it first (1-based, then 0-based variant)
247
388
  if (customBaseAddress !== undefined) {
248
389
  console.log(`Detect models for custom base address '${customBaseAddress}' ...`);
249
390
  // Try 0-based at custom address
250
391
  try {
251
- const sunspecId = await this.modbusClient.readRegisterStringValue(customBaseAddress, 2);
392
+ const sunspecId = await instance.readRegisterStringValue(customBaseAddress, 2);
252
393
  if (sunspecId.includes('SunS')) {
253
394
  console.log(`Detected 0-based addressing mode (base address: ${customBaseAddress})`);
254
395
  return {
@@ -263,7 +404,7 @@ export class SunspecModbusClient {
263
404
  }
264
405
  // Try 1-based at custom address (customBaseAddress + 1)
265
406
  try {
266
- const sunspecId = await this.modbusClient.readRegisterStringValue(customBaseAddress + 1, 2);
407
+ const sunspecId = await instance.readRegisterStringValue(customBaseAddress + 1, 2);
267
408
  if (sunspecId.includes('SunS')) {
268
409
  console.log(`Detected 1-based addressing mode (base address: ${customBaseAddress + 1})`);
269
410
  return {
@@ -280,7 +421,7 @@ export class SunspecModbusClient {
280
421
  else {
281
422
  // Try 1-based addressing first (most common)
282
423
  try {
283
- const sunspecId = await this.modbusClient.readRegisterStringValue(40001, 2);
424
+ const sunspecId = await instance.readRegisterStringValue(40001, 2);
284
425
  if (sunspecId.includes('SunS')) {
285
426
  console.log('Detected 1-based addressing mode (base address: 40001)');
286
427
  return {
@@ -295,7 +436,7 @@ export class SunspecModbusClient {
295
436
  }
296
437
  // Try 0-based addressing
297
438
  try {
298
- const sunspecId = await this.modbusClient.readRegisterStringValue(40000, 2);
439
+ const sunspecId = await instance.readRegisterStringValue(40000, 2);
299
440
  if (sunspecId.includes('SunS')) {
300
441
  console.log('Detected 0-based addressing mode (base address: 40000)');
301
442
  return {
@@ -315,27 +456,22 @@ export class SunspecModbusClient {
315
456
  throw new Error(`Device is not SunSpec compliant - "SunS" identifier not found at addresses ${addressesChecked}`);
316
457
  }
317
458
  /**
318
- * Discover all available Sunspec models
459
+ * Discover all available Sunspec models for a unit
319
460
  * Automatically detects base address (40000 or 40001) and scans from there
320
461
  */
321
- async discoverModels(customBaseAddress) {
322
- if (!this.connected) {
323
- throw new Error('Not connected to Modbus device');
324
- }
325
- this.discoveredModels.clear();
462
+ async discoverModels(unitId, customBaseAddress) {
463
+ const instance = this.getInstance(unitId);
464
+ const models = this.getModelsMap(unitId);
465
+ models.clear();
326
466
  const maxAddress = 50000; // Safety limit
327
467
  let currentAddress = 0;
328
- console.log('Starting Sunspec model discovery...');
468
+ console.log(`Starting Sunspec model discovery for unit ${unitId}...`);
329
469
  try {
330
470
  // Detect the base address and addressing mode
331
- const addressInfo = await this.detectSunspecBaseAddress(customBaseAddress);
471
+ const addressInfo = await this.detectSunspecBaseAddress(unitId, customBaseAddress);
332
472
  currentAddress = addressInfo.nextAddress;
333
473
  while (currentAddress < maxAddress) {
334
- // Read model ID and length
335
- if (!this.modbusClient) {
336
- throw new Error('Modbus client not initialized');
337
- }
338
- const buffer = await this.modbusClient.readHoldingRegisters(currentAddress, 2);
474
+ const buffer = await instance.readHoldingRegisters(currentAddress, 2);
339
475
  const modelData = [buffer.readUInt16BE(0), buffer.readUInt16BE(2)];
340
476
  if (!modelData || modelData.length < 2) {
341
477
  console.log(`No data at address ${currentAddress}, ending discovery`);
@@ -354,23 +490,23 @@ export class SunspecModbusClient {
354
490
  address: currentAddress,
355
491
  length: modelLength
356
492
  };
357
- this.discoveredModels.set(modelId, model);
358
- 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})`);
359
495
  // Jump to next model: current address + 2 (header) + model length
360
496
  currentAddress = currentAddress + 2 + modelLength;
361
497
  }
362
498
  }
363
499
  catch (error) {
364
- console.error(`Error during model discovery at address ${currentAddress}: ${error}`);
500
+ console.error(`Error during model discovery at address ${currentAddress} (unit ${unitId}): ${error}`);
365
501
  }
366
- console.log(`Discovery complete. Found ${this.discoveredModels.size} models`);
367
- return this.discoveredModels;
502
+ console.log(`Discovery complete for unit ${unitId}. Found ${models.size} models`);
503
+ return models;
368
504
  }
369
505
  /**
370
- * Find a specific model by ID
506
+ * Find a specific model by ID for a given unit
371
507
  */
372
- findModel(modelId) {
373
- return this.discoveredModels.get(modelId);
508
+ findModel(unitId, modelId) {
509
+ return this.discoveredModelsByUnit.get(unitId)?.get(modelId);
374
510
  }
375
511
  /**
376
512
  * Check if a value is "unimplemented" according to Sunspec specification
@@ -446,17 +582,15 @@ export class SunspecModbusClient {
446
582
  * Read an entire model's register block in a single Modbus call.
447
583
  * Returns a Buffer containing all registers for the model.
448
584
  */
449
- async readModelBlock(model) {
450
- if (!this.faultTolerantReader) {
451
- throw new Error('Fault-tolerant reader not initialized');
452
- }
585
+ async readModelBlock(unitId, model) {
586
+ const reader = this.getReader(unitId);
453
587
  // Read model.length + 2 registers: the 2-register header (ID + length) plus all data registers.
454
588
  // This way buffer offsets match the convention used throughout: offset 0 = model ID,
455
589
  // offset 1 = model length, offset 2 = first data register, etc.
456
590
  const totalRegisters = model.length + 2;
457
- const result = await this.faultTolerantReader.readHoldingRegisters(model.address, totalRegisters);
591
+ const result = await reader.readHoldingRegisters(model.address, totalRegisters);
458
592
  if (!result.success || !result.value) {
459
- 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'}`);
460
594
  }
461
595
  this.connectionHealth.recordSuccess();
462
596
  return result.value;
@@ -481,12 +615,10 @@ export class SunspecModbusClient {
481
615
  /**
482
616
  * Helper to read register value(s) using the fault-tolerant reader with data type conversion
483
617
  */
484
- async readRegisterValue(address, quantity = 1, dataType) {
485
- if (!this.faultTolerantReader) {
486
- throw new Error('Fault-tolerant reader not initialized');
487
- }
618
+ async readRegisterValue(unitId, address, quantity = 1, dataType) {
619
+ const reader = this.getReader(unitId);
488
620
  try {
489
- const result = await this.faultTolerantReader.readHoldingRegisters(address, quantity);
621
+ const result = await reader.readHoldingRegisters(address, quantity);
490
622
  // Check if the read was successful
491
623
  if (!result.success || !result.value) {
492
624
  throw new Error(`Failed to read register at address ${address}: ${result.error?.message || 'Unknown error'}`);
@@ -507,10 +639,8 @@ export class SunspecModbusClient {
507
639
  /**
508
640
  * Helper to write register value(s)
509
641
  */
510
- async writeRegisterValue(address, value, dataType = 'uint16') {
511
- if (!this.modbusClient) {
512
- throw new Error('Modbus client not initialized');
513
- }
642
+ async writeRegisterValue(unitId, address, value, dataType = 'uint16') {
643
+ const instance = this.getInstance(unitId);
514
644
  try {
515
645
  // Convert value to array of register values
516
646
  let registerValues;
@@ -539,9 +669,9 @@ export class SunspecModbusClient {
539
669
  }
540
670
  }
541
671
  // Write to holding registers
542
- await this.modbusClient.writeMultipleRegisters(address, registerValues);
672
+ await instance.writeMultipleRegisters(address, registerValues);
543
673
  this.connectionHealth.recordSuccess();
544
- console.log(`Successfully wrote value ${value} to register ${address}`);
674
+ console.log(`Successfully wrote value ${value} to register ${address} (unit ${unitId})`);
545
675
  return true;
546
676
  }
547
677
  catch (error) {
@@ -553,23 +683,23 @@ export class SunspecModbusClient {
553
683
  /**
554
684
  * Read inverter data from Model 101 (Single Phase) / Model 103 (Three Phase)
555
685
  */
556
- async readInverterData() {
557
- const model = this.findModel(SunspecModelId.Inverter3Phase);
686
+ async readInverterData(unitId) {
687
+ const model = this.findModel(unitId, SunspecModelId.Inverter3Phase);
558
688
  if (!model) {
559
- console.debug('Inverter model 103 not found, trying single phase model 101');
560
- 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);
561
691
  if (!singlePhaseModel) {
562
- console.error('No inverter model found');
692
+ console.error(`No inverter model found on unit ${unitId}`);
563
693
  return null;
564
694
  }
565
695
  console.warn('IMPORTANT: Working with single-phase inverter, but 3-phase is expected!');
566
- return this.readSinglePhaseInverterData(singlePhaseModel);
696
+ return this.readSinglePhaseInverterData(unitId, singlePhaseModel);
567
697
  }
568
- console.debug(`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})`);
569
699
  try {
570
700
  // Read entire model block in a single Modbus call
571
- console.debug(`Reading Inverter Data from Model ${model.id} at base address: ${model.address}`);
572
- 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);
573
703
  // Extract all scale factors from buffer
574
704
  const scaleFactors = this.extractInverterScaleFactors(buffer);
575
705
  // Extract raw values from buffer
@@ -650,11 +780,11 @@ export class SunspecModbusClient {
650
780
  /**
651
781
  * Read single phase inverter data (Model 101)
652
782
  */
653
- async readSinglePhaseInverterData(model) {
783
+ async readSinglePhaseInverterData(unitId, model) {
654
784
  try {
655
- console.debug(`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})`);
656
786
  // Read entire model block in a single Modbus call
657
- const buffer = await this.readModelBlock(model);
787
+ const buffer = await this.readModelBlock(unitId, model);
658
788
  // Extract scale factors from buffer
659
789
  const scaleFactors = {
660
790
  A_SF: this.extractValue(buffer, 6, 'int16'),
@@ -803,18 +933,18 @@ export class SunspecModbusClient {
803
933
  this.logRegisterRead(160, 5, 'DCWH_SF', scaleFactors.DCWH_SF, 'int16');
804
934
  return scaleFactors;
805
935
  }
806
- async readMPPTScaleFactors() {
807
- const model = this.findModel(SunspecModelId.MPPT);
936
+ async readMPPTScaleFactors(unitId) {
937
+ const model = this.findModel(unitId, SunspecModelId.MPPT);
808
938
  if (!model) {
809
- console.debug('MPPT model 160 not found');
939
+ console.debug(`MPPT model 160 not found on unit ${unitId}`);
810
940
  return null;
811
941
  }
812
942
  try {
813
- const buffer = await this.readModelBlock(model);
943
+ const buffer = await this.readModelBlock(unitId, model);
814
944
  return this.extractMPPTScaleFactors(buffer);
815
945
  }
816
946
  catch (error) {
817
- console.error(`Error reading MPPT scale factors: ${error}`);
947
+ console.error(`Error reading MPPT scale factors (unit ${unitId}): ${error}`);
818
948
  return null;
819
949
  }
820
950
  }
@@ -875,36 +1005,36 @@ export class SunspecModbusClient {
875
1005
  /**
876
1006
  * Read MPPT data from Model 160
877
1007
  */
878
- async readMPPTData(moduleId = 1) {
879
- const model = this.findModel(SunspecModelId.MPPT);
1008
+ async readMPPTData(unitId, moduleId = 1) {
1009
+ const model = this.findModel(unitId, SunspecModelId.MPPT);
880
1010
  if (!model) {
881
- console.debug('MPPT model 160 not found');
1011
+ console.debug(`MPPT model 160 not found on unit ${unitId}`);
882
1012
  return null;
883
1013
  }
884
1014
  try {
885
1015
  // Read entire model block in a single Modbus call
886
- const buffer = await this.readModelBlock(model);
1016
+ const buffer = await this.readModelBlock(unitId, model);
887
1017
  const scaleFactors = this.extractMPPTScaleFactors(buffer);
888
1018
  return this.extractMPPTModuleData(buffer, model, moduleId, scaleFactors);
889
1019
  }
890
1020
  catch (error) {
891
- console.error(`Error reading MPPT data for module ${moduleId}: ${error}`);
1021
+ console.error(`Error reading MPPT data for module ${moduleId} (unit ${unitId}): ${error}`);
892
1022
  return null;
893
1023
  }
894
1024
  }
895
1025
  /**
896
1026
  * Read all MPPT strings from Model 160 (Multiple MPPT)
897
1027
  */
898
- async readAllMPPTData() {
1028
+ async readAllMPPTData(unitId) {
899
1029
  const mpptData = [];
900
- const model = this.findModel(SunspecModelId.MPPT);
1030
+ const model = this.findModel(unitId, SunspecModelId.MPPT);
901
1031
  if (!model) {
902
- console.debug('MPPT model 160 not found');
1032
+ console.debug(`MPPT model 160 not found on unit ${unitId}`);
903
1033
  return [];
904
1034
  }
905
1035
  try {
906
1036
  // Read entire model block in a single Modbus call
907
- const buffer = await this.readModelBlock(model);
1037
+ const buffer = await this.readModelBlock(unitId, model);
908
1038
  const scaleFactors = this.extractMPPTScaleFactors(buffer);
909
1039
  // Read the module count from register 8
910
1040
  let moduleCount = 4; // Default fallback value
@@ -1065,16 +1195,16 @@ export class SunspecModbusClient {
1065
1195
  /**
1066
1196
  * Read battery base data from Model 802 (Battery Base)
1067
1197
  */
1068
- async readBatteryBaseData() {
1069
- const model = this.findModel(SunspecModelId.BatteryBase);
1198
+ async readBatteryBaseData(unitId) {
1199
+ const model = this.findModel(unitId, SunspecModelId.BatteryBase);
1070
1200
  if (!model) {
1071
- console.debug('Battery Base model 802 not found');
1201
+ console.debug(`Battery Base model 802 not found on unit ${unitId}`);
1072
1202
  return null;
1073
1203
  }
1074
- console.debug(`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})`);
1075
1205
  try {
1076
1206
  // Read entire model block in a single Modbus call
1077
- const buffer = await this.readModelBlock(model);
1207
+ const buffer = await this.readModelBlock(unitId, model);
1078
1208
  // Extract scale factors from buffer (offsets 52-63)
1079
1209
  const sf = this.extractBatteryBaseScaleFactors(buffer);
1080
1210
  // Extract raw values from buffer
@@ -1203,27 +1333,27 @@ export class SunspecModbusClient {
1203
1333
  /**
1204
1334
  * Read battery data from Model 124 (Basic Storage) with fallback to Model 802 / Model 803
1205
1335
  */
1206
- async readBatteryData() {
1336
+ async readBatteryData(unitId) {
1207
1337
  // Try Model 124 first (Basic Storage Controls)
1208
- let model = this.findModel(SunspecModelId.Battery);
1338
+ let model = this.findModel(unitId, SunspecModelId.Battery);
1209
1339
  // Fall back to other battery models if needed
1210
1340
  if (!model) {
1211
- model = this.findModel(SunspecModelId.BatteryBase);
1341
+ model = this.findModel(unitId, SunspecModelId.BatteryBase);
1212
1342
  }
1213
1343
  if (!model) {
1214
- model = this.findModel(SunspecModelId.BatteryControl);
1344
+ model = this.findModel(unitId, SunspecModelId.BatteryControl);
1215
1345
  }
1216
1346
  if (!model) {
1217
- console.debug('No battery model found');
1347
+ console.debug(`No battery model found on unit ${unitId}`);
1218
1348
  return null;
1219
1349
  }
1220
- console.debug(`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})`);
1221
1351
  try {
1222
1352
  if (model.id === 124) {
1223
1353
  // Model 124: Basic Storage Controls
1224
1354
  console.debug('Using Model 124 (Basic Storage Controls)');
1225
1355
  // Read entire model block in a single Modbus call
1226
- const buffer = await this.readModelBlock(model);
1356
+ const buffer = await this.readModelBlock(unitId, model);
1227
1357
  // Extract scale factors from buffer (offsets 18-25)
1228
1358
  const scaleFactors = {
1229
1359
  WChaMax_SF: this.extractValue(buffer, 18, 'int16'),
@@ -1324,7 +1454,7 @@ export class SunspecModbusClient {
1324
1454
  else if (model.id === 802) {
1325
1455
  // Model 802: Battery Base
1326
1456
  console.debug('Using Model 802 (Battery Base)');
1327
- const baseData = await this.readBatteryBaseData();
1457
+ const baseData = await this.readBatteryBaseData(unitId);
1328
1458
  if (!baseData) {
1329
1459
  return null;
1330
1460
  }
@@ -1376,55 +1506,55 @@ export class SunspecModbusClient {
1376
1506
  /**
1377
1507
  * Write battery control settings to Model 124
1378
1508
  */
1379
- async writeBatteryControls(controls) {
1380
- const model = this.findModel(SunspecModelId.Battery);
1509
+ async writeBatteryControls(unitId, controls) {
1510
+ const model = this.findModel(unitId, SunspecModelId.Battery);
1381
1511
  if (!model) {
1382
- console.error('Battery model 124 not found');
1512
+ console.error(`Battery model 124 not found on unit ${unitId}`);
1383
1513
  return false;
1384
1514
  }
1385
1515
  const baseAddr = model.address;
1386
- 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})`);
1387
1517
  try {
1388
1518
  // Write storage control mode (Register 5)
1389
1519
  if (controls.storCtlMod !== undefined) {
1390
- await this.writeRegisterValue(baseAddr + 5, controls.storCtlMod, 'uint16');
1520
+ await this.writeRegisterValue(unitId, baseAddr + 5, controls.storCtlMod, 'uint16');
1391
1521
  console.log(`Set storage control mode to 0x${controls.storCtlMod.toString(16)}`);
1392
1522
  }
1393
1523
  // Write charge source setting (Register 17)
1394
1524
  if (controls.chaGriSet !== undefined) {
1395
- await this.writeRegisterValue(baseAddr + 17, controls.chaGriSet, 'uint16');
1525
+ await this.writeRegisterValue(unitId, baseAddr + 17, controls.chaGriSet, 'uint16');
1396
1526
  console.log(`Set charge source to ${controls.chaGriSet === SunspecChargeSource.GRID ? 'GRID' : 'PV'}`);
1397
1527
  }
1398
1528
  // Write maximum charge power (Register 2) - needs scale factor
1399
1529
  if (controls.wChaMax !== undefined) {
1400
1530
  const scaleFactorAddr = baseAddr + 18;
1401
- const scaleFactor = await this.readRegisterValue(scaleFactorAddr, 1, 'int16');
1531
+ const scaleFactor = await this.readRegisterValue(unitId, scaleFactorAddr, 1, 'int16');
1402
1532
  const scaledValue = Math.round(controls.wChaMax / Math.pow(10, scaleFactor));
1403
- await this.writeRegisterValue(baseAddr + 2, scaledValue, 'uint16');
1533
+ await this.writeRegisterValue(unitId, baseAddr + 2, scaledValue, 'uint16');
1404
1534
  console.log(`Set max charge power to ${controls.wChaMax}W (scaled: ${scaledValue})`);
1405
1535
  }
1406
1536
  // Write charge rate (Register 13) - needs scale factor
1407
1537
  if (controls.inWRte !== undefined) {
1408
1538
  const scaleFactorAddr = baseAddr + 25;
1409
- const scaleFactor = await this.readRegisterValue(scaleFactorAddr, 1, 'int16');
1539
+ const scaleFactor = await this.readRegisterValue(unitId, scaleFactorAddr, 1, 'int16');
1410
1540
  const scaledValue = Math.round(controls.inWRte / Math.pow(10, scaleFactor));
1411
- await this.writeRegisterValue(baseAddr + 13, scaledValue, 'int16');
1541
+ await this.writeRegisterValue(unitId, baseAddr + 13, scaledValue, 'int16');
1412
1542
  console.log(`Set charge rate to ${controls.inWRte}% (scaled: ${scaledValue})`);
1413
1543
  }
1414
1544
  // Write discharge rate (Register 12) - needs scale factor
1415
1545
  if (controls.outWRte !== undefined) {
1416
1546
  const scaleFactorAddr = baseAddr + 25;
1417
- const scaleFactor = await this.readRegisterValue(scaleFactorAddr, 1, 'int16');
1547
+ const scaleFactor = await this.readRegisterValue(unitId, scaleFactorAddr, 1, 'int16');
1418
1548
  const scaledValue = Math.round(controls.outWRte / Math.pow(10, scaleFactor));
1419
- await this.writeRegisterValue(baseAddr + 12, scaledValue, 'int16');
1549
+ await this.writeRegisterValue(unitId, baseAddr + 12, scaledValue, 'int16');
1420
1550
  console.log(`Set discharge rate to ${controls.outWRte}% (scaled: ${scaledValue})`);
1421
1551
  }
1422
1552
  // Write minimum reserve percentage (Register 7) - needs scale factor
1423
1553
  if (controls.minRsvPct !== undefined) {
1424
1554
  const scaleFactorAddr = baseAddr + 21;
1425
- const scaleFactor = await this.readRegisterValue(scaleFactorAddr, 1, 'int16');
1555
+ const scaleFactor = await this.readRegisterValue(unitId, scaleFactorAddr, 1, 'int16');
1426
1556
  const scaledValue = Math.round(controls.minRsvPct / Math.pow(10, scaleFactor));
1427
- await this.writeRegisterValue(baseAddr + 7, scaledValue, 'uint16');
1557
+ await this.writeRegisterValue(unitId, baseAddr + 7, scaledValue, 'uint16');
1428
1558
  console.log(`Set minimum reserve to ${controls.minRsvPct}% (scaled: ${scaledValue})`);
1429
1559
  }
1430
1560
  console.log('Battery controls written successfully');
@@ -1438,7 +1568,7 @@ export class SunspecModbusClient {
1438
1568
  /**
1439
1569
  * Set battery storage mode (simplified interface)
1440
1570
  */
1441
- async setStorageMode(mode) {
1571
+ async setStorageMode(unitId, mode) {
1442
1572
  let storCtlMod;
1443
1573
  switch (mode) {
1444
1574
  case SunspecStorageMode.CHARGE:
@@ -1462,29 +1592,29 @@ export class SunspecModbusClient {
1462
1592
  return false;
1463
1593
  }
1464
1594
  console.log(`Setting storage mode to ${mode} (control bits: 0x${storCtlMod.toString(16)})`);
1465
- return this.writeBatteryControls({ storCtlMod });
1595
+ return this.writeBatteryControls(unitId, { storCtlMod });
1466
1596
  }
1467
1597
  /**
1468
1598
  * Enable or disable grid charging
1469
1599
  */
1470
- async enableGridCharging(enable) {
1600
+ async enableGridCharging(unitId, enable) {
1471
1601
  const chaGriSet = enable ? SunspecChargeSource.GRID : SunspecChargeSource.PV;
1472
- console.log(`${enable ? 'Enabling' : 'Disabling'} grid charging`);
1473
- return this.writeBatteryControls({ chaGriSet });
1602
+ console.log(`${enable ? 'Enabling' : 'Disabling'} grid charging on unit ${unitId}`);
1603
+ return this.writeBatteryControls(unitId, { chaGriSet });
1474
1604
  }
1475
1605
  /**
1476
1606
  * Read battery control settings from Model 124 (Basic Storage Controls)
1477
1607
  */
1478
- async readBatteryControls() {
1479
- const model = this.findModel(SunspecModelId.Battery);
1608
+ async readBatteryControls(unitId) {
1609
+ const model = this.findModel(unitId, SunspecModelId.Battery);
1480
1610
  if (!model) {
1481
- console.log('Battery model 124 not found');
1611
+ console.log(`Battery model 124 not found on unit ${unitId}`);
1482
1612
  return null;
1483
1613
  }
1484
- 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})`);
1485
1615
  try {
1486
1616
  // Read entire model block in a single Modbus call
1487
- const buffer = await this.readModelBlock(model);
1617
+ const buffer = await this.readModelBlock(unitId, model);
1488
1618
  // Extract scale factors from buffer
1489
1619
  const scaleFactors = {
1490
1620
  WChaMax_SF: this.extractValue(buffer, 18, 'int16'),
@@ -1523,19 +1653,19 @@ export class SunspecModbusClient {
1523
1653
  /**
1524
1654
  * Read meter data from Model 201 (Single Phase) / Model 203 (Three Phase) / Model 204 (Split Phase)
1525
1655
  */
1526
- async readMeterData() {
1527
- let model = this.findModel(SunspecModelId.Meter3Phase);
1656
+ async readMeterData(unitId) {
1657
+ let model = this.findModel(unitId, SunspecModelId.Meter3Phase);
1528
1658
  if (!model) {
1529
- model = this.findModel(SunspecModelId.MeterWye);
1659
+ model = this.findModel(unitId, SunspecModelId.MeterWye);
1530
1660
  }
1531
1661
  if (!model) {
1532
- model = this.findModel(SunspecModelId.MeterSinglePhase);
1662
+ model = this.findModel(unitId, SunspecModelId.MeterSinglePhase);
1533
1663
  }
1534
1664
  if (!model) {
1535
- console.debug('No meter model found');
1665
+ console.debug(`No meter model found on unit ${unitId}`);
1536
1666
  return null;
1537
1667
  }
1538
- console.debug(`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})`);
1539
1669
  try {
1540
1670
  // Different meter models have different register offsets
1541
1671
  console.debug(`Meter is Model ${model.id}`);
@@ -1579,7 +1709,7 @@ export class SunspecModbusClient {
1579
1709
  energySFOffset = 54; // TotWh_SF - Total Energy scale factor
1580
1710
  }
1581
1711
  // Read entire model block in a single Modbus call
1582
- const buffer = await this.readModelBlock(model);
1712
+ const buffer = await this.readModelBlock(unitId, model);
1583
1713
  // Extract scale factors from buffer
1584
1714
  const powerSF = this.extractValue(buffer, powerSFOffset, 'int16');
1585
1715
  const freqSF = this.extractValue(buffer, freqSFOffset, 'int16');
@@ -1617,16 +1747,16 @@ export class SunspecModbusClient {
1617
1747
  /**
1618
1748
  * Read common block data (Model 1)
1619
1749
  */
1620
- async readCommonBlock() {
1621
- const model = this.findModel(SunspecModelId.Common);
1750
+ async readCommonBlock(unitId) {
1751
+ const model = this.findModel(unitId, SunspecModelId.Common);
1622
1752
  if (!model) {
1623
- console.error('Common block model not found');
1753
+ console.error(`Common block model not found on unit ${unitId}`);
1624
1754
  return null;
1625
1755
  }
1626
- console.log(`Reading Common Block - Model address: ${model.address}`);
1756
+ console.log(`Reading Common Block - Model address: ${model.address} (unit ${unitId})`);
1627
1757
  try {
1628
1758
  // Read entire model block in a single Modbus call
1629
- const buffer = await this.readModelBlock(model);
1759
+ const buffer = await this.readModelBlock(unitId, model);
1630
1760
  // Common block offsets are relative to the model start (after ID and Length header,
1631
1761
  // but readModelBlock reads from model.address which includes the data area).
1632
1762
  // The offsets below are relative to the data start within the model block.
@@ -1663,24 +1793,24 @@ export class SunspecModbusClient {
1663
1793
  /**
1664
1794
  * Get serial number from device
1665
1795
  */
1666
- async getSerialNumber() {
1667
- const commonData = await this.readCommonBlock();
1796
+ async getSerialNumber(unitId) {
1797
+ const commonData = await this.readCommonBlock(unitId);
1668
1798
  return commonData?.serialNumber;
1669
1799
  }
1670
1800
  /**
1671
- * Check if connected
1801
+ * Check if a specific unit is connected on this network device
1672
1802
  */
1673
- isConnected() {
1674
- return this.connected;
1803
+ isConnected(unitId) {
1804
+ return this.modbusInstances.has(unitId);
1675
1805
  }
1676
1806
  /**
1677
- * Check if connection is healthy
1807
+ * Check if a specific unit's connection is healthy
1678
1808
  */
1679
- isHealthy() {
1680
- return this.connected && this.connectionHealth.isHealthy();
1809
+ isHealthy(unitId) {
1810
+ return this.modbusInstances.has(unitId) && this.connectionHealth.isHealthy();
1681
1811
  }
1682
1812
  /**
1683
- * Get connection health details
1813
+ * Get connection health details (shared across all units on this network device)
1684
1814
  */
1685
1815
  getConnectionHealth() {
1686
1816
  return this.connectionHealth;
@@ -1688,15 +1818,15 @@ export class SunspecModbusClient {
1688
1818
  /**
1689
1819
  * Read inverter settings from Model 121 (Inverter Settings)
1690
1820
  */
1691
- async readInverterSettings() {
1692
- const model = this.findModel(SunspecModelId.Settings);
1821
+ async readInverterSettings(unitId) {
1822
+ const model = this.findModel(unitId, SunspecModelId.Settings);
1693
1823
  if (!model) {
1694
- console.debug('Settings model 121 not found');
1824
+ console.debug(`Settings model 121 not found on unit ${unitId}`);
1695
1825
  return null;
1696
1826
  }
1697
1827
  try {
1698
1828
  // Read entire model block in a single Modbus call
1699
- const buffer = await this.readModelBlock(model);
1829
+ const buffer = await this.readModelBlock(unitId, model);
1700
1830
  // Extract scale factors from buffer (offsets 22-31)
1701
1831
  const scaleFactors = {
1702
1832
  WMax_SF: this.extractValue(buffer, 22, 'int16'),
@@ -1780,15 +1910,15 @@ export class SunspecModbusClient {
1780
1910
  /**
1781
1911
  * Read inverter controls from Model 123 (Immediate Inverter Controls)
1782
1912
  */
1783
- async readInverterControls() {
1784
- const model = this.findModel(SunspecModelId.Controls);
1913
+ async readInverterControls(unitId) {
1914
+ const model = this.findModel(unitId, SunspecModelId.Controls);
1785
1915
  if (!model) {
1786
- console.log('Controls model 123 not found');
1916
+ console.log(`Controls model 123 not found on unit ${unitId}`);
1787
1917
  return null;
1788
1918
  }
1789
1919
  try {
1790
1920
  // Read entire model block in a single Modbus call
1791
- const buffer = await this.readModelBlock(model);
1921
+ const buffer = await this.readModelBlock(unitId, model);
1792
1922
  // Extract scale factors from buffer (offsets 21-23)
1793
1923
  const scaleFactors = {
1794
1924
  WMaxLimPct_SF: this.extractValue(buffer, 21, 'int16'),
@@ -1875,27 +2005,28 @@ export class SunspecModbusClient {
1875
2005
  /**
1876
2006
  * Write Block 121 - Inverter Basic Settings
1877
2007
  */
1878
- async writeInverterSettings(settings) {
1879
- const model = this.findModel(SunspecModelId.Settings);
2008
+ async writeInverterSettings(unitId, settings) {
2009
+ const model = this.findModel(unitId, SunspecModelId.Settings);
1880
2010
  if (!model) {
1881
- console.error('Settings model 121 not found');
2011
+ console.error(`Settings model 121 not found on unit ${unitId}`);
1882
2012
  return false;
1883
2013
  }
1884
2014
  const baseAddr = model.address;
1885
2015
  try {
2016
+ const instance = this.getInstance(unitId);
1886
2017
  // For each setting, write the value if provided
1887
2018
  // Note: This is a simplified implementation. In production, you'd batch writes
1888
- if (settings.WMax !== undefined && this.modbusClient) {
2019
+ if (settings.WMax !== undefined) {
1889
2020
  // Need to read scale factor first if not provided
1890
- const sfBuffer = await this.modbusClient.readHoldingRegisters(baseAddr + 22, 1);
2021
+ const sfBuffer = await instance.readHoldingRegisters(baseAddr + 22, 1);
1891
2022
  const scaleFactor = sfBuffer.readInt16BE(0);
1892
2023
  const scaledValue = Math.round(settings.WMax / Math.pow(10, scaleFactor));
1893
2024
  // Writing registers needs to be implemented in EnergyAppModbusInstance
1894
2025
  // For now, log the write operation
1895
2026
  console.log(`Would write value ${scaledValue} to register ${baseAddr}`);
1896
2027
  }
1897
- if (settings.VRef !== undefined && this.modbusClient) {
1898
- const sfBuffer = await this.modbusClient.readHoldingRegisters(baseAddr + 23, 1);
2028
+ if (settings.VRef !== undefined) {
2029
+ const sfBuffer = await instance.readHoldingRegisters(baseAddr + 23, 1);
1899
2030
  const scaleFactor = sfBuffer.readInt16BE(0);
1900
2031
  const scaledValue = Math.round(settings.VRef / Math.pow(10, scaleFactor));
1901
2032
  console.log(`Would write value ${scaledValue} to register ${baseAddr + 1}`);
@@ -1912,41 +2043,41 @@ export class SunspecModbusClient {
1912
2043
  /**
1913
2044
  * Write inverter controls to Model 123 (Immediate Inverter Controls)
1914
2045
  */
1915
- async writeInverterControls(controls) {
1916
- const model = this.findModel(SunspecModelId.Controls);
2046
+ async writeInverterControls(unitId, controls) {
2047
+ const model = this.findModel(unitId, SunspecModelId.Controls);
1917
2048
  if (!model) {
1918
- console.error('Controls model 123 not found');
2049
+ console.error(`Controls model 123 not found on unit ${unitId}`);
1919
2050
  return false;
1920
2051
  }
1921
2052
  const baseAddr = model.address;
1922
2053
  try {
1923
2054
  // Connection control (Register 2)
1924
2055
  if (controls.Conn !== undefined) {
1925
- await this.writeRegisterValue(baseAddr + 2, controls.Conn, 'uint16');
2056
+ await this.writeRegisterValue(unitId, baseAddr + 2, controls.Conn, 'uint16');
1926
2057
  console.log(`Set connection control to ${controls.Conn}`);
1927
2058
  }
1928
2059
  // Power limit control (Register 3) - needs scale factor
1929
2060
  if (controls.WMaxLimPct !== undefined) {
1930
- const scaleFactor = await this.readRegisterValue(baseAddr + 21, 1, 'int16');
2061
+ const scaleFactor = await this.readRegisterValue(unitId, baseAddr + 21, 1, 'int16');
1931
2062
  const scaledValue = Math.round(controls.WMaxLimPct / Math.pow(10, scaleFactor));
1932
- await this.writeRegisterValue(baseAddr + 3, scaledValue, 'uint16');
2063
+ await this.writeRegisterValue(unitId, baseAddr + 3, scaledValue, 'uint16');
1933
2064
  console.log(`Set power limit to ${controls.WMaxLimPct}% (scaled: ${scaledValue})`);
1934
2065
  }
1935
2066
  // Throttle enable/disable (Register 7)
1936
2067
  if (controls.WMaxLim_Ena !== undefined) {
1937
- await this.writeRegisterValue(baseAddr + 7, controls.WMaxLim_Ena, 'uint16');
2068
+ await this.writeRegisterValue(unitId, baseAddr + 7, controls.WMaxLim_Ena, 'uint16');
1938
2069
  console.log(`Set throttle enable to ${controls.WMaxLim_Ena}`);
1939
2070
  }
1940
2071
  // Power factor control (Register 8) - needs scale factor
1941
2072
  if (controls.OutPFSet !== undefined) {
1942
- const scaleFactor = await this.readRegisterValue(baseAddr + 22, 1, 'int16');
2073
+ const scaleFactor = await this.readRegisterValue(unitId, baseAddr + 22, 1, 'int16');
1943
2074
  const scaledValue = Math.round(controls.OutPFSet / Math.pow(10, scaleFactor));
1944
- await this.writeRegisterValue(baseAddr + 8, scaledValue, 'int16');
2075
+ await this.writeRegisterValue(unitId, baseAddr + 8, scaledValue, 'int16');
1945
2076
  console.log(`Set power factor to ${controls.OutPFSet} (scaled: ${scaledValue})`);
1946
2077
  }
1947
2078
  // Power factor enable/disable (Register 12)
1948
2079
  if (controls.OutPFSet_Ena !== undefined) {
1949
- await this.writeRegisterValue(baseAddr + 12, controls.OutPFSet_Ena, 'uint16');
2080
+ await this.writeRegisterValue(unitId, baseAddr + 12, controls.OutPFSet_Ena, 'uint16');
1950
2081
  console.log(`Set PF enable to ${controls.OutPFSet_Ena}`);
1951
2082
  }
1952
2083
  console.log('Inverter controls written successfully');
@@ -1967,21 +2098,21 @@ export class SunspecModbusClient {
1967
2098
  * @param limitW - Power limit in Watts, or null to remove the limit
1968
2099
  * @returns true if successful, false otherwise
1969
2100
  */
1970
- async setFeedInLimit(limitW) {
2101
+ async setFeedInLimit(unitId, limitW) {
1971
2102
  if (limitW === null) {
1972
2103
  // Remove limit: disable WMaxLim_Ena
1973
- console.log('Removing feed-in limit (disabling WMaxLim_Ena)');
1974
- 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 });
1975
2106
  }
1976
2107
  // Read WMax from Model 121 to compute percentage
1977
- const settings = await this.readInverterSettings();
2108
+ const settings = await this.readInverterSettings(unitId);
1978
2109
  if (!settings || !settings.WMax) {
1979
- 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`);
1980
2111
  return false;
1981
2112
  }
1982
2113
  const pct = (limitW / settings.WMax) * 100;
1983
- console.log(`Setting feed-in limit to ${limitW}W (${pct.toFixed(2)}% of WMax ${settings.WMax}W)`);
1984
- 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, {
1985
2116
  WMaxLimPct: pct,
1986
2117
  WMaxLim_Ena: SunspecEnableControl.ENABLED
1987
2118
  });