@enyo-energy/sunspec-sdk 0.0.51 → 0.0.54
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/sunspec-devices.cjs +45 -42
- package/dist/cjs/sunspec-interfaces.cjs +3 -0
- package/dist/cjs/sunspec-interfaces.d.cts +7 -2
- package/dist/cjs/sunspec-modbus-client.cjs +490 -277
- package/dist/cjs/sunspec-modbus-client.d.cts +115 -53
- package/dist/cjs/version.cjs +1 -1
- package/dist/cjs/version.d.cts +1 -1
- package/dist/sunspec-devices.js +45 -42
- package/dist/sunspec-interfaces.d.ts +7 -2
- package/dist/sunspec-interfaces.js +3 -0
- package/dist/sunspec-modbus-client.d.ts +115 -53
- package/dist/sunspec-modbus-client.js +488 -277
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
|
@@ -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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
36
|
-
//
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
63
|
-
|
|
119
|
+
else {
|
|
120
|
+
this.connectionParams = { primaryHost: host, secondaryHost, port };
|
|
64
121
|
}
|
|
65
|
-
this.
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
132
|
+
* Disconnect from all units of this network device.
|
|
72
133
|
*
|
|
73
|
-
* Note: connection parameters are preserved so reconnect() can
|
|
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 (
|
|
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
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
90
|
-
* First tries primaryHost (hostname), then falls back to secondaryHost
|
|
91
|
-
* Returns true if
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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}
|
|
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}
|
|
108
|
-
const secondarySuccess = await this.attemptConnection(secondaryHost, port,
|
|
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}
|
|
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
|
-
*
|
|
120
|
-
*
|
|
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,
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
128
|
-
|
|
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
|
|
138
|
-
* just-opened
|
|
139
|
-
* lock and ensure
|
|
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
|
|
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
|
-
|
|
154
|
-
this.
|
|
155
|
-
this.
|
|
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.
|
|
167
|
-
this.
|
|
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
|
|
290
|
+
* Close the Modbus instance for a single unit. Idempotent. Caller must hold the
|
|
291
|
+
* connection lock.
|
|
174
292
|
*/
|
|
175
|
-
async
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
this.
|
|
181
|
-
|
|
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
|
|
301
|
+
await instance.disconnect();
|
|
187
302
|
}
|
|
188
303
|
catch { /* ignore */ }
|
|
189
304
|
}
|
|
190
|
-
if (
|
|
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.
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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.
|
|
214
|
-
this.
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
323
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 ${
|
|
367
|
-
return
|
|
502
|
+
console.log(`Discovery complete for unit ${unitId}. Found ${models.size} models: [${[...models.keys()].sort((a, b) => a - b).join(', ')}]`);
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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) {
|
|
@@ -551,28 +681,36 @@ export class SunspecModbusClient {
|
|
|
551
681
|
}
|
|
552
682
|
}
|
|
553
683
|
/**
|
|
554
|
-
* Read inverter data
|
|
684
|
+
* Read inverter data. Detects which SunSpec inverter model the device exposes —
|
|
685
|
+
* int+SF (101/103) or float (111/112/113) — by checking the discovered model directory,
|
|
686
|
+
* and dispatches to the appropriate decoder.
|
|
555
687
|
*/
|
|
556
|
-
async readInverterData() {
|
|
557
|
-
const
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
688
|
+
async readInverterData(unitId) {
|
|
689
|
+
const tryOrder = [
|
|
690
|
+
{ id: 103, reader: m => this.readThreePhaseInverterData_IntSF(unitId, m) },
|
|
691
|
+
{ id: 113, reader: m => this.readFloatInverterData(unitId, m, 113) },
|
|
692
|
+
{ id: 112, reader: m => this.readFloatInverterData(unitId, m, 112) },
|
|
693
|
+
{ id: 101, reader: m => this.readSinglePhaseInverterData(unitId, m) },
|
|
694
|
+
{ id: 111, reader: m => this.readFloatInverterData(unitId, m, 111) },
|
|
695
|
+
];
|
|
696
|
+
for (const { id, reader } of tryOrder) {
|
|
697
|
+
const model = this.findModel(unitId, id);
|
|
698
|
+
if (model) {
|
|
699
|
+
console.debug(`Using inverter Model ${id} at address ${model.address} (length ${model.length}) on unit ${unitId}`);
|
|
700
|
+
return reader(model);
|
|
564
701
|
}
|
|
565
|
-
console.warn('IMPORTANT: Working with single-phase inverter, but 3-phase is expected!');
|
|
566
|
-
return this.readSinglePhaseInverterData(singlePhaseModel);
|
|
567
702
|
}
|
|
568
|
-
console.debug(`
|
|
703
|
+
console.debug(`No inverter model (101/103/111/112/113) on unit ${unitId}`);
|
|
704
|
+
return null;
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* Read three-phase inverter data from Model 103 (int + scale factor encoding).
|
|
708
|
+
*/
|
|
709
|
+
async readThreePhaseInverterData_IntSF(unitId, model) {
|
|
569
710
|
try {
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
const buffer = await this.readModelBlock(model);
|
|
573
|
-
// Extract all scale factors from buffer
|
|
711
|
+
console.debug(`Reading Inverter Data from Model 103 at base address: ${model.address} (unit ${unitId})`);
|
|
712
|
+
const buffer = await this.readModelBlock(unitId, model);
|
|
574
713
|
const scaleFactors = this.extractInverterScaleFactors(buffer);
|
|
575
|
-
// Extract raw values from buffer
|
|
576
714
|
const acCurrentRaw = this.extractValue(buffer, 2, 'uint16');
|
|
577
715
|
const voltageANRaw = this.extractValue(buffer, 10, 'uint16');
|
|
578
716
|
const voltageBNRaw = this.extractValue(buffer, 11, 'uint16');
|
|
@@ -624,7 +762,6 @@ export class SunspecModbusClient {
|
|
|
624
762
|
vendorEvents3: this.extractValue(buffer, 48, 'uint32', 2),
|
|
625
763
|
vendorEvents4: this.extractValue(buffer, 50, 'uint32', 2)
|
|
626
764
|
};
|
|
627
|
-
// Log non-scaled fields
|
|
628
765
|
this.logRegisterRead(103, 38, 'Operating State', data.operatingState, 'enum16');
|
|
629
766
|
this.logRegisterRead(103, 39, 'Vendor State', data.vendorState, 'uint16');
|
|
630
767
|
this.logRegisterRead(103, 40, 'Events', data.events, 'bitfield32');
|
|
@@ -633,7 +770,6 @@ export class SunspecModbusClient {
|
|
|
633
770
|
this.logRegisterRead(103, 46, 'Vendor Events 2', data.vendorEvents2, 'bitfield32');
|
|
634
771
|
this.logRegisterRead(103, 48, 'Vendor Events 3', data.vendorEvents3, 'bitfield32');
|
|
635
772
|
this.logRegisterRead(103, 50, 'Vendor Events 4', data.vendorEvents4, 'bitfield32');
|
|
636
|
-
// Read AC Energy (32-bit accumulator) - Offset 24-25
|
|
637
773
|
const acEnergy = this.extractValue(buffer, 24, 'uint32', 2);
|
|
638
774
|
this.logRegisterRead(103, 24, 'AC Energy', acEnergy, 'acc32');
|
|
639
775
|
data.acEnergy = !this.isUnimplementedValue(acEnergy, 'acc32')
|
|
@@ -650,49 +786,49 @@ export class SunspecModbusClient {
|
|
|
650
786
|
/**
|
|
651
787
|
* Read single phase inverter data (Model 101)
|
|
652
788
|
*/
|
|
653
|
-
async readSinglePhaseInverterData(model) {
|
|
789
|
+
async readSinglePhaseInverterData(unitId, model) {
|
|
654
790
|
try {
|
|
655
|
-
console.debug(`Reading Single-Phase Inverter Data from Model 101 at base address: ${model.address}`);
|
|
791
|
+
console.debug(`Reading Single-Phase Inverter Data from Model 101 at base address: ${model.address} (unit ${unitId})`);
|
|
656
792
|
// Read entire model block in a single Modbus call
|
|
657
|
-
const buffer = await this.readModelBlock(model);
|
|
658
|
-
// Extract scale factors from buffer
|
|
793
|
+
const buffer = await this.readModelBlock(unitId, model);
|
|
794
|
+
// Extract scale factors from buffer (offsets aligned with Model 103 reader, which works on real
|
|
795
|
+
// hardware; differs from the published SunSpec Model 101 spec for DC fields).
|
|
659
796
|
const scaleFactors = {
|
|
660
797
|
A_SF: this.extractValue(buffer, 6, 'int16'),
|
|
661
798
|
V_SF: this.extractValue(buffer, 13, 'int16'),
|
|
662
|
-
W_SF: this.extractValue(buffer,
|
|
663
|
-
Hz_SF: this.extractValue(buffer,
|
|
799
|
+
W_SF: this.extractValue(buffer, 15, 'int16'),
|
|
800
|
+
Hz_SF: this.extractValue(buffer, 17, 'int16'),
|
|
664
801
|
WH_SF: this.extractValue(buffer, 26, 'int16'),
|
|
665
|
-
DCA_SF: this.extractValue(buffer,
|
|
666
|
-
DCV_SF: this.extractValue(buffer,
|
|
667
|
-
DCW_SF: this.extractValue(buffer,
|
|
802
|
+
DCA_SF: this.extractValue(buffer, 28, 'int16'),
|
|
803
|
+
DCV_SF: this.extractValue(buffer, 29, 'int16'),
|
|
804
|
+
DCW_SF: this.extractValue(buffer, 31, 'int16')
|
|
668
805
|
};
|
|
669
806
|
this.logRegisterRead(101, 6, 'A_SF', scaleFactors.A_SF, 'int16');
|
|
670
807
|
this.logRegisterRead(101, 13, 'V_SF', scaleFactors.V_SF, 'int16');
|
|
671
|
-
this.logRegisterRead(101,
|
|
672
|
-
this.logRegisterRead(101,
|
|
808
|
+
this.logRegisterRead(101, 15, 'W_SF', scaleFactors.W_SF, 'int16');
|
|
809
|
+
this.logRegisterRead(101, 17, 'Hz_SF', scaleFactors.Hz_SF, 'int16');
|
|
673
810
|
this.logRegisterRead(101, 26, 'WH_SF', scaleFactors.WH_SF, 'int16');
|
|
674
|
-
this.logRegisterRead(101,
|
|
675
|
-
this.logRegisterRead(101,
|
|
676
|
-
this.logRegisterRead(101,
|
|
677
|
-
// Extract raw values from buffer
|
|
811
|
+
this.logRegisterRead(101, 28, 'DCA_SF', scaleFactors.DCA_SF, 'int16');
|
|
812
|
+
this.logRegisterRead(101, 29, 'DCV_SF', scaleFactors.DCV_SF, 'int16');
|
|
813
|
+
this.logRegisterRead(101, 31, 'DCW_SF', scaleFactors.DCW_SF, 'int16');
|
|
678
814
|
const acCurrentRaw = this.extractValue(buffer, 2, 'uint16');
|
|
679
|
-
const voltageRaw = this.extractValue(buffer,
|
|
680
|
-
const acPowerRaw = this.extractValue(buffer,
|
|
681
|
-
const freqRaw = this.extractValue(buffer,
|
|
682
|
-
const dcCurrentRaw = this.extractValue(buffer,
|
|
683
|
-
const dcVoltageRaw = this.extractValue(buffer,
|
|
684
|
-
const dcPowerRaw = this.extractValue(buffer,
|
|
685
|
-
const stateRaw = this.extractValue(buffer,
|
|
686
|
-
this.logRegisterRead(101,
|
|
815
|
+
const voltageRaw = this.extractValue(buffer, 10, 'uint16');
|
|
816
|
+
const acPowerRaw = this.extractValue(buffer, 14, 'int16');
|
|
817
|
+
const freqRaw = this.extractValue(buffer, 16, 'uint16');
|
|
818
|
+
const dcCurrentRaw = this.extractValue(buffer, 27, 'uint16');
|
|
819
|
+
const dcVoltageRaw = this.extractValue(buffer, 28, 'uint16');
|
|
820
|
+
const dcPowerRaw = this.extractValue(buffer, 30, 'int16');
|
|
821
|
+
const stateRaw = this.extractValue(buffer, 38, 'uint16');
|
|
822
|
+
this.logRegisterRead(101, 38, 'Operating State', stateRaw, 'enum16');
|
|
687
823
|
const data = {
|
|
688
824
|
blockNumber: 101,
|
|
689
|
-
voltageAN: this.applyScaleFactor(voltageRaw, scaleFactors.V_SF, 'uint16', 'Voltage AN',
|
|
825
|
+
voltageAN: this.applyScaleFactor(voltageRaw, scaleFactors.V_SF, 'uint16', 'Voltage AN', 10, 101),
|
|
690
826
|
acCurrent: this.applyScaleFactor(acCurrentRaw, scaleFactors.A_SF, 'uint16', 'AC Current', 2, 101),
|
|
691
|
-
acPower: this.applyScaleFactor(acPowerRaw, scaleFactors.W_SF, 'int16', 'AC Power',
|
|
692
|
-
frequency: this.applyScaleFactor(freqRaw, scaleFactors.Hz_SF, 'uint16', 'Frequency',
|
|
693
|
-
dcCurrent: this.applyScaleFactor(dcCurrentRaw, scaleFactors.DCA_SF, 'uint16', 'DC Current',
|
|
694
|
-
dcVoltage: this.applyScaleFactor(dcVoltageRaw, scaleFactors.DCV_SF, 'uint16', 'DC Voltage',
|
|
695
|
-
dcPower: this.applyScaleFactor(dcPowerRaw, scaleFactors.DCW_SF, 'int16', 'DC Power',
|
|
827
|
+
acPower: this.applyScaleFactor(acPowerRaw, scaleFactors.W_SF, 'int16', 'AC Power', 14, 101),
|
|
828
|
+
frequency: this.applyScaleFactor(freqRaw, scaleFactors.Hz_SF, 'uint16', 'Frequency', 16, 101),
|
|
829
|
+
dcCurrent: this.applyScaleFactor(dcCurrentRaw, scaleFactors.DCA_SF, 'uint16', 'DC Current', 27, 101),
|
|
830
|
+
dcVoltage: this.applyScaleFactor(dcVoltageRaw, scaleFactors.DCV_SF, 'uint16', 'DC Voltage', 28, 101),
|
|
831
|
+
dcPower: this.applyScaleFactor(dcPowerRaw, scaleFactors.DCW_SF, 'int16', 'DC Power', 30, 101),
|
|
696
832
|
operatingState: stateRaw
|
|
697
833
|
};
|
|
698
834
|
// Read AC Energy (32-bit accumulator) - Offset 24-25
|
|
@@ -709,6 +845,70 @@ export class SunspecModbusClient {
|
|
|
709
845
|
return null;
|
|
710
846
|
}
|
|
711
847
|
}
|
|
848
|
+
/**
|
|
849
|
+
* Read inverter data from a float-variant model: 111 (single-phase), 112 (split-phase), or 113 (three-phase).
|
|
850
|
+
*
|
|
851
|
+
* All three models share the same SunSpec register layout — measurement fields are 32-bit IEEE 754 floats
|
|
852
|
+
* (no scale factors), status registers stay enum16, event registers stay bitfield32. Phase scope differs
|
|
853
|
+
* by model: 111 populates phase A only; 112 phases A+B; 113 all three. Unpopulated phase fields read NaN
|
|
854
|
+
* and are returned as undefined.
|
|
855
|
+
*/
|
|
856
|
+
async readFloatInverterData(unitId, model, blockNumber) {
|
|
857
|
+
try {
|
|
858
|
+
console.debug(`Reading Float Inverter Data from Model ${blockNumber} at base address: ${model.address} (unit ${unitId})`);
|
|
859
|
+
const buffer = await this.readModelBlock(unitId, model);
|
|
860
|
+
const data = {
|
|
861
|
+
blockNumber,
|
|
862
|
+
blockAddress: model.address,
|
|
863
|
+
blockLength: model.length,
|
|
864
|
+
acCurrent: this.extractFloat32OrUndefined(buffer, 2, blockNumber, 'AC Current'),
|
|
865
|
+
phaseACurrent: this.extractFloat32OrUndefined(buffer, 4, blockNumber, 'Phase A Current'),
|
|
866
|
+
phaseBCurrent: this.extractFloat32OrUndefined(buffer, 6, blockNumber, 'Phase B Current'),
|
|
867
|
+
phaseCCurrent: this.extractFloat32OrUndefined(buffer, 8, blockNumber, 'Phase C Current'),
|
|
868
|
+
voltageAB: this.extractFloat32OrUndefined(buffer, 10, blockNumber, 'Voltage AB'),
|
|
869
|
+
voltageBC: this.extractFloat32OrUndefined(buffer, 12, blockNumber, 'Voltage BC'),
|
|
870
|
+
voltageCA: this.extractFloat32OrUndefined(buffer, 14, blockNumber, 'Voltage CA'),
|
|
871
|
+
voltageAN: this.extractFloat32OrUndefined(buffer, 16, blockNumber, 'Voltage AN'),
|
|
872
|
+
voltageBN: this.extractFloat32OrUndefined(buffer, 18, blockNumber, 'Voltage BN'),
|
|
873
|
+
voltageCN: this.extractFloat32OrUndefined(buffer, 20, blockNumber, 'Voltage CN'),
|
|
874
|
+
acPower: this.extractFloat32OrUndefined(buffer, 22, blockNumber, 'AC Power'),
|
|
875
|
+
frequency: this.extractFloat32OrUndefined(buffer, 24, blockNumber, 'Frequency'),
|
|
876
|
+
apparentPower: this.extractFloat32OrUndefined(buffer, 26, blockNumber, 'Apparent Power'),
|
|
877
|
+
reactivePower: this.extractFloat32OrUndefined(buffer, 28, blockNumber, 'Reactive Power'),
|
|
878
|
+
powerFactor: this.extractFloat32OrUndefined(buffer, 30, blockNumber, 'Power Factor'),
|
|
879
|
+
acEnergy: this.extractFloat32OrUndefined(buffer, 32, blockNumber, 'AC Energy'),
|
|
880
|
+
dcCurrent: this.extractFloat32OrUndefined(buffer, 34, blockNumber, 'DC Current'),
|
|
881
|
+
dcVoltage: this.extractFloat32OrUndefined(buffer, 36, blockNumber, 'DC Voltage'),
|
|
882
|
+
dcPower: this.extractFloat32OrUndefined(buffer, 38, blockNumber, 'DC Power'),
|
|
883
|
+
cabinetTemperature: this.extractFloat32OrUndefined(buffer, 40, blockNumber, 'Cabinet Temperature'),
|
|
884
|
+
heatSinkTemperature: this.extractFloat32OrUndefined(buffer, 42, blockNumber, 'Heat Sink Temperature'),
|
|
885
|
+
transformerTemperature: this.extractFloat32OrUndefined(buffer, 44, blockNumber, 'Transformer Temperature'),
|
|
886
|
+
otherTemperature: this.extractFloat32OrUndefined(buffer, 46, blockNumber, 'Other Temperature'),
|
|
887
|
+
operatingState: this.extractValue(buffer, 48, 'uint16'),
|
|
888
|
+
vendorState: this.extractValue(buffer, 49, 'uint16'),
|
|
889
|
+
events: this.extractValue(buffer, 50, 'uint32', 2),
|
|
890
|
+
events2: this.extractValue(buffer, 52, 'uint32', 2),
|
|
891
|
+
vendorEvents1: this.extractValue(buffer, 54, 'uint32', 2),
|
|
892
|
+
vendorEvents2: this.extractValue(buffer, 56, 'uint32', 2),
|
|
893
|
+
vendorEvents3: this.extractValue(buffer, 58, 'uint32', 2),
|
|
894
|
+
vendorEvents4: this.extractValue(buffer, 60, 'uint32', 2),
|
|
895
|
+
};
|
|
896
|
+
this.logRegisterRead(blockNumber, 48, 'Operating State', data.operatingState, 'enum16');
|
|
897
|
+
this.logRegisterRead(blockNumber, 49, 'Vendor State', data.vendorState, 'enum16');
|
|
898
|
+
this.logRegisterRead(blockNumber, 50, 'Events', data.events, 'bitfield32');
|
|
899
|
+
this.logRegisterRead(blockNumber, 52, 'Events2', data.events2, 'bitfield32');
|
|
900
|
+
this.logRegisterRead(blockNumber, 54, 'Vendor Events 1', data.vendorEvents1, 'bitfield32');
|
|
901
|
+
this.logRegisterRead(blockNumber, 56, 'Vendor Events 2', data.vendorEvents2, 'bitfield32');
|
|
902
|
+
this.logRegisterRead(blockNumber, 58, 'Vendor Events 3', data.vendorEvents3, 'bitfield32');
|
|
903
|
+
this.logRegisterRead(blockNumber, 60, 'Vendor Events 4', data.vendorEvents4, 'bitfield32');
|
|
904
|
+
console.debug(`[Model ${blockNumber}] Float Inverter Data:`, data);
|
|
905
|
+
return data;
|
|
906
|
+
}
|
|
907
|
+
catch (error) {
|
|
908
|
+
console.error(`Error reading float inverter data (Model ${blockNumber}): ${error}`);
|
|
909
|
+
return null;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
712
912
|
/**
|
|
713
913
|
* Extract inverter scale factors from a pre-read model buffer
|
|
714
914
|
*/
|
|
@@ -776,6 +976,16 @@ export class SunspecModbusClient {
|
|
|
776
976
|
console.debug(`[Model ${modelId}] offset ${offset}: ${fieldName} = "${rawValue}"${typeInfo}`);
|
|
777
977
|
}
|
|
778
978
|
}
|
|
979
|
+
/**
|
|
980
|
+
* Read a float32 (IEEE 754, big-endian, 2 registers) from a model buffer.
|
|
981
|
+
* SunSpec uses NaN as the "unimplemented" sentinel for floats; returns undefined in that case.
|
|
982
|
+
*/
|
|
983
|
+
extractFloat32OrUndefined(buffer, offset, modelId, fieldName) {
|
|
984
|
+
const value = this.extractValue(buffer, offset, 'float32', 2);
|
|
985
|
+
const display = Number.isNaN(value) ? 'NaN' : value;
|
|
986
|
+
console.debug(`[Model ${modelId}] offset ${offset}: ${fieldName} = ${display} (float32)`);
|
|
987
|
+
return Number.isNaN(value) ? undefined : value;
|
|
988
|
+
}
|
|
779
989
|
/**
|
|
780
990
|
* Read scale factors from Model 160 (MPPT)
|
|
781
991
|
*
|
|
@@ -803,18 +1013,18 @@ export class SunspecModbusClient {
|
|
|
803
1013
|
this.logRegisterRead(160, 5, 'DCWH_SF', scaleFactors.DCWH_SF, 'int16');
|
|
804
1014
|
return scaleFactors;
|
|
805
1015
|
}
|
|
806
|
-
async readMPPTScaleFactors() {
|
|
807
|
-
const model = this.findModel(SunspecModelId.MPPT);
|
|
1016
|
+
async readMPPTScaleFactors(unitId) {
|
|
1017
|
+
const model = this.findModel(unitId, SunspecModelId.MPPT);
|
|
808
1018
|
if (!model) {
|
|
809
|
-
console.debug(
|
|
1019
|
+
console.debug(`MPPT model 160 not found on unit ${unitId}`);
|
|
810
1020
|
return null;
|
|
811
1021
|
}
|
|
812
1022
|
try {
|
|
813
|
-
const buffer = await this.readModelBlock(model);
|
|
1023
|
+
const buffer = await this.readModelBlock(unitId, model);
|
|
814
1024
|
return this.extractMPPTScaleFactors(buffer);
|
|
815
1025
|
}
|
|
816
1026
|
catch (error) {
|
|
817
|
-
console.error(`Error reading MPPT scale factors: ${error}`);
|
|
1027
|
+
console.error(`Error reading MPPT scale factors (unit ${unitId}): ${error}`);
|
|
818
1028
|
return null;
|
|
819
1029
|
}
|
|
820
1030
|
}
|
|
@@ -875,36 +1085,36 @@ export class SunspecModbusClient {
|
|
|
875
1085
|
/**
|
|
876
1086
|
* Read MPPT data from Model 160
|
|
877
1087
|
*/
|
|
878
|
-
async readMPPTData(moduleId = 1) {
|
|
879
|
-
const model = this.findModel(SunspecModelId.MPPT);
|
|
1088
|
+
async readMPPTData(unitId, moduleId = 1) {
|
|
1089
|
+
const model = this.findModel(unitId, SunspecModelId.MPPT);
|
|
880
1090
|
if (!model) {
|
|
881
|
-
console.debug(
|
|
1091
|
+
console.debug(`MPPT model 160 not found on unit ${unitId}`);
|
|
882
1092
|
return null;
|
|
883
1093
|
}
|
|
884
1094
|
try {
|
|
885
1095
|
// Read entire model block in a single Modbus call
|
|
886
|
-
const buffer = await this.readModelBlock(model);
|
|
1096
|
+
const buffer = await this.readModelBlock(unitId, model);
|
|
887
1097
|
const scaleFactors = this.extractMPPTScaleFactors(buffer);
|
|
888
1098
|
return this.extractMPPTModuleData(buffer, model, moduleId, scaleFactors);
|
|
889
1099
|
}
|
|
890
1100
|
catch (error) {
|
|
891
|
-
console.error(`Error reading MPPT data for module ${moduleId}: ${error}`);
|
|
1101
|
+
console.error(`Error reading MPPT data for module ${moduleId} (unit ${unitId}): ${error}`);
|
|
892
1102
|
return null;
|
|
893
1103
|
}
|
|
894
1104
|
}
|
|
895
1105
|
/**
|
|
896
1106
|
* Read all MPPT strings from Model 160 (Multiple MPPT)
|
|
897
1107
|
*/
|
|
898
|
-
async readAllMPPTData() {
|
|
1108
|
+
async readAllMPPTData(unitId) {
|
|
899
1109
|
const mpptData = [];
|
|
900
|
-
const model = this.findModel(SunspecModelId.MPPT);
|
|
1110
|
+
const model = this.findModel(unitId, SunspecModelId.MPPT);
|
|
901
1111
|
if (!model) {
|
|
902
|
-
console.debug(
|
|
1112
|
+
console.debug(`MPPT model 160 not found on unit ${unitId}`);
|
|
903
1113
|
return [];
|
|
904
1114
|
}
|
|
905
1115
|
try {
|
|
906
1116
|
// Read entire model block in a single Modbus call
|
|
907
|
-
const buffer = await this.readModelBlock(model);
|
|
1117
|
+
const buffer = await this.readModelBlock(unitId, model);
|
|
908
1118
|
const scaleFactors = this.extractMPPTScaleFactors(buffer);
|
|
909
1119
|
// Read the module count from register 8
|
|
910
1120
|
let moduleCount = 4; // Default fallback value
|
|
@@ -1065,16 +1275,16 @@ export class SunspecModbusClient {
|
|
|
1065
1275
|
/**
|
|
1066
1276
|
* Read battery base data from Model 802 (Battery Base)
|
|
1067
1277
|
*/
|
|
1068
|
-
async readBatteryBaseData() {
|
|
1069
|
-
const model = this.findModel(SunspecModelId.BatteryBase);
|
|
1278
|
+
async readBatteryBaseData(unitId) {
|
|
1279
|
+
const model = this.findModel(unitId, SunspecModelId.BatteryBase);
|
|
1070
1280
|
if (!model) {
|
|
1071
|
-
console.debug(
|
|
1281
|
+
console.debug(`Battery Base model 802 not found on unit ${unitId}`);
|
|
1072
1282
|
return null;
|
|
1073
1283
|
}
|
|
1074
|
-
console.debug(`Reading Battery Base Data from Model 802 at base address: ${model.address}`);
|
|
1284
|
+
console.debug(`Reading Battery Base Data from Model 802 at base address: ${model.address} (unit ${unitId})`);
|
|
1075
1285
|
try {
|
|
1076
1286
|
// Read entire model block in a single Modbus call
|
|
1077
|
-
const buffer = await this.readModelBlock(model);
|
|
1287
|
+
const buffer = await this.readModelBlock(unitId, model);
|
|
1078
1288
|
// Extract scale factors from buffer (offsets 52-63)
|
|
1079
1289
|
const sf = this.extractBatteryBaseScaleFactors(buffer);
|
|
1080
1290
|
// Extract raw values from buffer
|
|
@@ -1203,27 +1413,27 @@ export class SunspecModbusClient {
|
|
|
1203
1413
|
/**
|
|
1204
1414
|
* Read battery data from Model 124 (Basic Storage) with fallback to Model 802 / Model 803
|
|
1205
1415
|
*/
|
|
1206
|
-
async readBatteryData() {
|
|
1416
|
+
async readBatteryData(unitId) {
|
|
1207
1417
|
// Try Model 124 first (Basic Storage Controls)
|
|
1208
|
-
let model = this.findModel(SunspecModelId.Battery);
|
|
1418
|
+
let model = this.findModel(unitId, SunspecModelId.Battery);
|
|
1209
1419
|
// Fall back to other battery models if needed
|
|
1210
1420
|
if (!model) {
|
|
1211
|
-
model = this.findModel(SunspecModelId.BatteryBase);
|
|
1421
|
+
model = this.findModel(unitId, SunspecModelId.BatteryBase);
|
|
1212
1422
|
}
|
|
1213
1423
|
if (!model) {
|
|
1214
|
-
model = this.findModel(SunspecModelId.BatteryControl);
|
|
1424
|
+
model = this.findModel(unitId, SunspecModelId.BatteryControl);
|
|
1215
1425
|
}
|
|
1216
1426
|
if (!model) {
|
|
1217
|
-
console.debug(
|
|
1427
|
+
console.debug(`No battery model found on unit ${unitId}`);
|
|
1218
1428
|
return null;
|
|
1219
1429
|
}
|
|
1220
|
-
console.debug(`Reading Battery Data from Model ${model.id} at base address: ${model.address}`);
|
|
1430
|
+
console.debug(`Reading Battery Data from Model ${model.id} at base address: ${model.address} (unit ${unitId})`);
|
|
1221
1431
|
try {
|
|
1222
1432
|
if (model.id === 124) {
|
|
1223
1433
|
// Model 124: Basic Storage Controls
|
|
1224
1434
|
console.debug('Using Model 124 (Basic Storage Controls)');
|
|
1225
1435
|
// Read entire model block in a single Modbus call
|
|
1226
|
-
const buffer = await this.readModelBlock(model);
|
|
1436
|
+
const buffer = await this.readModelBlock(unitId, model);
|
|
1227
1437
|
// Extract scale factors from buffer (offsets 18-25)
|
|
1228
1438
|
const scaleFactors = {
|
|
1229
1439
|
WChaMax_SF: this.extractValue(buffer, 18, 'int16'),
|
|
@@ -1324,7 +1534,7 @@ export class SunspecModbusClient {
|
|
|
1324
1534
|
else if (model.id === 802) {
|
|
1325
1535
|
// Model 802: Battery Base
|
|
1326
1536
|
console.debug('Using Model 802 (Battery Base)');
|
|
1327
|
-
const baseData = await this.readBatteryBaseData();
|
|
1537
|
+
const baseData = await this.readBatteryBaseData(unitId);
|
|
1328
1538
|
if (!baseData) {
|
|
1329
1539
|
return null;
|
|
1330
1540
|
}
|
|
@@ -1376,55 +1586,55 @@ export class SunspecModbusClient {
|
|
|
1376
1586
|
/**
|
|
1377
1587
|
* Write battery control settings to Model 124
|
|
1378
1588
|
*/
|
|
1379
|
-
async writeBatteryControls(controls) {
|
|
1380
|
-
const model = this.findModel(SunspecModelId.Battery);
|
|
1589
|
+
async writeBatteryControls(unitId, controls) {
|
|
1590
|
+
const model = this.findModel(unitId, SunspecModelId.Battery);
|
|
1381
1591
|
if (!model) {
|
|
1382
|
-
console.error(
|
|
1592
|
+
console.error(`Battery model 124 not found on unit ${unitId}`);
|
|
1383
1593
|
return false;
|
|
1384
1594
|
}
|
|
1385
1595
|
const baseAddr = model.address;
|
|
1386
|
-
console.log(`Writing Battery Controls to Model 124 at base address: ${baseAddr}`);
|
|
1596
|
+
console.log(`Writing Battery Controls to Model 124 at base address: ${baseAddr} (unit ${unitId})`);
|
|
1387
1597
|
try {
|
|
1388
1598
|
// Write storage control mode (Register 5)
|
|
1389
1599
|
if (controls.storCtlMod !== undefined) {
|
|
1390
|
-
await this.writeRegisterValue(baseAddr + 5, controls.storCtlMod, 'uint16');
|
|
1600
|
+
await this.writeRegisterValue(unitId, baseAddr + 5, controls.storCtlMod, 'uint16');
|
|
1391
1601
|
console.log(`Set storage control mode to 0x${controls.storCtlMod.toString(16)}`);
|
|
1392
1602
|
}
|
|
1393
1603
|
// Write charge source setting (Register 17)
|
|
1394
1604
|
if (controls.chaGriSet !== undefined) {
|
|
1395
|
-
await this.writeRegisterValue(baseAddr + 17, controls.chaGriSet, 'uint16');
|
|
1605
|
+
await this.writeRegisterValue(unitId, baseAddr + 17, controls.chaGriSet, 'uint16');
|
|
1396
1606
|
console.log(`Set charge source to ${controls.chaGriSet === SunspecChargeSource.GRID ? 'GRID' : 'PV'}`);
|
|
1397
1607
|
}
|
|
1398
1608
|
// Write maximum charge power (Register 2) - needs scale factor
|
|
1399
1609
|
if (controls.wChaMax !== undefined) {
|
|
1400
1610
|
const scaleFactorAddr = baseAddr + 18;
|
|
1401
|
-
const scaleFactor = await this.readRegisterValue(scaleFactorAddr, 1, 'int16');
|
|
1611
|
+
const scaleFactor = await this.readRegisterValue(unitId, scaleFactorAddr, 1, 'int16');
|
|
1402
1612
|
const scaledValue = Math.round(controls.wChaMax / Math.pow(10, scaleFactor));
|
|
1403
|
-
await this.writeRegisterValue(baseAddr + 2, scaledValue, 'uint16');
|
|
1613
|
+
await this.writeRegisterValue(unitId, baseAddr + 2, scaledValue, 'uint16');
|
|
1404
1614
|
console.log(`Set max charge power to ${controls.wChaMax}W (scaled: ${scaledValue})`);
|
|
1405
1615
|
}
|
|
1406
1616
|
// Write charge rate (Register 13) - needs scale factor
|
|
1407
1617
|
if (controls.inWRte !== undefined) {
|
|
1408
1618
|
const scaleFactorAddr = baseAddr + 25;
|
|
1409
|
-
const scaleFactor = await this.readRegisterValue(scaleFactorAddr, 1, 'int16');
|
|
1619
|
+
const scaleFactor = await this.readRegisterValue(unitId, scaleFactorAddr, 1, 'int16');
|
|
1410
1620
|
const scaledValue = Math.round(controls.inWRte / Math.pow(10, scaleFactor));
|
|
1411
|
-
await this.writeRegisterValue(baseAddr + 13, scaledValue, 'int16');
|
|
1621
|
+
await this.writeRegisterValue(unitId, baseAddr + 13, scaledValue, 'int16');
|
|
1412
1622
|
console.log(`Set charge rate to ${controls.inWRte}% (scaled: ${scaledValue})`);
|
|
1413
1623
|
}
|
|
1414
1624
|
// Write discharge rate (Register 12) - needs scale factor
|
|
1415
1625
|
if (controls.outWRte !== undefined) {
|
|
1416
1626
|
const scaleFactorAddr = baseAddr + 25;
|
|
1417
|
-
const scaleFactor = await this.readRegisterValue(scaleFactorAddr, 1, 'int16');
|
|
1627
|
+
const scaleFactor = await this.readRegisterValue(unitId, scaleFactorAddr, 1, 'int16');
|
|
1418
1628
|
const scaledValue = Math.round(controls.outWRte / Math.pow(10, scaleFactor));
|
|
1419
|
-
await this.writeRegisterValue(baseAddr + 12, scaledValue, 'int16');
|
|
1629
|
+
await this.writeRegisterValue(unitId, baseAddr + 12, scaledValue, 'int16');
|
|
1420
1630
|
console.log(`Set discharge rate to ${controls.outWRte}% (scaled: ${scaledValue})`);
|
|
1421
1631
|
}
|
|
1422
1632
|
// Write minimum reserve percentage (Register 7) - needs scale factor
|
|
1423
1633
|
if (controls.minRsvPct !== undefined) {
|
|
1424
1634
|
const scaleFactorAddr = baseAddr + 21;
|
|
1425
|
-
const scaleFactor = await this.readRegisterValue(scaleFactorAddr, 1, 'int16');
|
|
1635
|
+
const scaleFactor = await this.readRegisterValue(unitId, scaleFactorAddr, 1, 'int16');
|
|
1426
1636
|
const scaledValue = Math.round(controls.minRsvPct / Math.pow(10, scaleFactor));
|
|
1427
|
-
await this.writeRegisterValue(baseAddr + 7, scaledValue, 'uint16');
|
|
1637
|
+
await this.writeRegisterValue(unitId, baseAddr + 7, scaledValue, 'uint16');
|
|
1428
1638
|
console.log(`Set minimum reserve to ${controls.minRsvPct}% (scaled: ${scaledValue})`);
|
|
1429
1639
|
}
|
|
1430
1640
|
console.log('Battery controls written successfully');
|
|
@@ -1438,7 +1648,7 @@ export class SunspecModbusClient {
|
|
|
1438
1648
|
/**
|
|
1439
1649
|
* Set battery storage mode (simplified interface)
|
|
1440
1650
|
*/
|
|
1441
|
-
async setStorageMode(mode) {
|
|
1651
|
+
async setStorageMode(unitId, mode) {
|
|
1442
1652
|
let storCtlMod;
|
|
1443
1653
|
switch (mode) {
|
|
1444
1654
|
case SunspecStorageMode.CHARGE:
|
|
@@ -1462,29 +1672,29 @@ export class SunspecModbusClient {
|
|
|
1462
1672
|
return false;
|
|
1463
1673
|
}
|
|
1464
1674
|
console.log(`Setting storage mode to ${mode} (control bits: 0x${storCtlMod.toString(16)})`);
|
|
1465
|
-
return this.writeBatteryControls({ storCtlMod });
|
|
1675
|
+
return this.writeBatteryControls(unitId, { storCtlMod });
|
|
1466
1676
|
}
|
|
1467
1677
|
/**
|
|
1468
1678
|
* Enable or disable grid charging
|
|
1469
1679
|
*/
|
|
1470
|
-
async enableGridCharging(enable) {
|
|
1680
|
+
async enableGridCharging(unitId, enable) {
|
|
1471
1681
|
const chaGriSet = enable ? SunspecChargeSource.GRID : SunspecChargeSource.PV;
|
|
1472
|
-
console.log(`${enable ? 'Enabling' : 'Disabling'} grid charging`);
|
|
1473
|
-
return this.writeBatteryControls({ chaGriSet });
|
|
1682
|
+
console.log(`${enable ? 'Enabling' : 'Disabling'} grid charging on unit ${unitId}`);
|
|
1683
|
+
return this.writeBatteryControls(unitId, { chaGriSet });
|
|
1474
1684
|
}
|
|
1475
1685
|
/**
|
|
1476
1686
|
* Read battery control settings from Model 124 (Basic Storage Controls)
|
|
1477
1687
|
*/
|
|
1478
|
-
async readBatteryControls() {
|
|
1479
|
-
const model = this.findModel(SunspecModelId.Battery);
|
|
1688
|
+
async readBatteryControls(unitId) {
|
|
1689
|
+
const model = this.findModel(unitId, SunspecModelId.Battery);
|
|
1480
1690
|
if (!model) {
|
|
1481
|
-
console.log(
|
|
1691
|
+
console.log(`Battery model 124 not found on unit ${unitId}`);
|
|
1482
1692
|
return null;
|
|
1483
1693
|
}
|
|
1484
|
-
console.log(`Reading Battery Controls from Model 124 at base address: ${model.address}`);
|
|
1694
|
+
console.log(`Reading Battery Controls from Model 124 at base address: ${model.address} (unit ${unitId})`);
|
|
1485
1695
|
try {
|
|
1486
1696
|
// Read entire model block in a single Modbus call
|
|
1487
|
-
const buffer = await this.readModelBlock(model);
|
|
1697
|
+
const buffer = await this.readModelBlock(unitId, model);
|
|
1488
1698
|
// Extract scale factors from buffer
|
|
1489
1699
|
const scaleFactors = {
|
|
1490
1700
|
WChaMax_SF: this.extractValue(buffer, 18, 'int16'),
|
|
@@ -1523,19 +1733,19 @@ export class SunspecModbusClient {
|
|
|
1523
1733
|
/**
|
|
1524
1734
|
* Read meter data from Model 201 (Single Phase) / Model 203 (Three Phase) / Model 204 (Split Phase)
|
|
1525
1735
|
*/
|
|
1526
|
-
async readMeterData() {
|
|
1527
|
-
let model = this.findModel(SunspecModelId.Meter3Phase);
|
|
1736
|
+
async readMeterData(unitId) {
|
|
1737
|
+
let model = this.findModel(unitId, SunspecModelId.Meter3Phase);
|
|
1528
1738
|
if (!model) {
|
|
1529
|
-
model = this.findModel(SunspecModelId.MeterWye);
|
|
1739
|
+
model = this.findModel(unitId, SunspecModelId.MeterWye);
|
|
1530
1740
|
}
|
|
1531
1741
|
if (!model) {
|
|
1532
|
-
model = this.findModel(SunspecModelId.MeterSinglePhase);
|
|
1742
|
+
model = this.findModel(unitId, SunspecModelId.MeterSinglePhase);
|
|
1533
1743
|
}
|
|
1534
1744
|
if (!model) {
|
|
1535
|
-
console.debug(
|
|
1745
|
+
console.debug(`No meter model found on unit ${unitId}`);
|
|
1536
1746
|
return null;
|
|
1537
1747
|
}
|
|
1538
|
-
console.debug(`Reading Meter Data from Model ${model.id} at base address: ${model.address}`);
|
|
1748
|
+
console.debug(`Reading Meter Data from Model ${model.id} at base address: ${model.address} (unit ${unitId})`);
|
|
1539
1749
|
try {
|
|
1540
1750
|
// Different meter models have different register offsets
|
|
1541
1751
|
console.debug(`Meter is Model ${model.id}`);
|
|
@@ -1579,7 +1789,7 @@ export class SunspecModbusClient {
|
|
|
1579
1789
|
energySFOffset = 54; // TotWh_SF - Total Energy scale factor
|
|
1580
1790
|
}
|
|
1581
1791
|
// Read entire model block in a single Modbus call
|
|
1582
|
-
const buffer = await this.readModelBlock(model);
|
|
1792
|
+
const buffer = await this.readModelBlock(unitId, model);
|
|
1583
1793
|
// Extract scale factors from buffer
|
|
1584
1794
|
const powerSF = this.extractValue(buffer, powerSFOffset, 'int16');
|
|
1585
1795
|
const freqSF = this.extractValue(buffer, freqSFOffset, 'int16');
|
|
@@ -1617,16 +1827,16 @@ export class SunspecModbusClient {
|
|
|
1617
1827
|
/**
|
|
1618
1828
|
* Read common block data (Model 1)
|
|
1619
1829
|
*/
|
|
1620
|
-
async readCommonBlock() {
|
|
1621
|
-
const model = this.findModel(SunspecModelId.Common);
|
|
1830
|
+
async readCommonBlock(unitId) {
|
|
1831
|
+
const model = this.findModel(unitId, SunspecModelId.Common);
|
|
1622
1832
|
if (!model) {
|
|
1623
|
-
console.error(
|
|
1833
|
+
console.error(`Common block model not found on unit ${unitId}`);
|
|
1624
1834
|
return null;
|
|
1625
1835
|
}
|
|
1626
|
-
console.log(`Reading Common Block - Model address: ${model.address}`);
|
|
1836
|
+
console.log(`Reading Common Block - Model address: ${model.address} (unit ${unitId})`);
|
|
1627
1837
|
try {
|
|
1628
1838
|
// Read entire model block in a single Modbus call
|
|
1629
|
-
const buffer = await this.readModelBlock(model);
|
|
1839
|
+
const buffer = await this.readModelBlock(unitId, model);
|
|
1630
1840
|
// Common block offsets are relative to the model start (after ID and Length header,
|
|
1631
1841
|
// but readModelBlock reads from model.address which includes the data area).
|
|
1632
1842
|
// The offsets below are relative to the data start within the model block.
|
|
@@ -1663,24 +1873,24 @@ export class SunspecModbusClient {
|
|
|
1663
1873
|
/**
|
|
1664
1874
|
* Get serial number from device
|
|
1665
1875
|
*/
|
|
1666
|
-
async getSerialNumber() {
|
|
1667
|
-
const commonData = await this.readCommonBlock();
|
|
1876
|
+
async getSerialNumber(unitId) {
|
|
1877
|
+
const commonData = await this.readCommonBlock(unitId);
|
|
1668
1878
|
return commonData?.serialNumber;
|
|
1669
1879
|
}
|
|
1670
1880
|
/**
|
|
1671
|
-
* Check if connected
|
|
1881
|
+
* Check if a specific unit is connected on this network device
|
|
1672
1882
|
*/
|
|
1673
|
-
isConnected() {
|
|
1674
|
-
return this.
|
|
1883
|
+
isConnected(unitId) {
|
|
1884
|
+
return this.modbusInstances.has(unitId);
|
|
1675
1885
|
}
|
|
1676
1886
|
/**
|
|
1677
|
-
* Check if connection is healthy
|
|
1887
|
+
* Check if a specific unit's connection is healthy
|
|
1678
1888
|
*/
|
|
1679
|
-
isHealthy() {
|
|
1680
|
-
return this.
|
|
1889
|
+
isHealthy(unitId) {
|
|
1890
|
+
return this.modbusInstances.has(unitId) && this.connectionHealth.isHealthy();
|
|
1681
1891
|
}
|
|
1682
1892
|
/**
|
|
1683
|
-
* Get connection health details
|
|
1893
|
+
* Get connection health details (shared across all units on this network device)
|
|
1684
1894
|
*/
|
|
1685
1895
|
getConnectionHealth() {
|
|
1686
1896
|
return this.connectionHealth;
|
|
@@ -1688,15 +1898,15 @@ export class SunspecModbusClient {
|
|
|
1688
1898
|
/**
|
|
1689
1899
|
* Read inverter settings from Model 121 (Inverter Settings)
|
|
1690
1900
|
*/
|
|
1691
|
-
async readInverterSettings() {
|
|
1692
|
-
const model = this.findModel(SunspecModelId.Settings);
|
|
1901
|
+
async readInverterSettings(unitId) {
|
|
1902
|
+
const model = this.findModel(unitId, SunspecModelId.Settings);
|
|
1693
1903
|
if (!model) {
|
|
1694
|
-
console.debug(
|
|
1904
|
+
console.debug(`Settings model 121 not found on unit ${unitId}`);
|
|
1695
1905
|
return null;
|
|
1696
1906
|
}
|
|
1697
1907
|
try {
|
|
1698
1908
|
// Read entire model block in a single Modbus call
|
|
1699
|
-
const buffer = await this.readModelBlock(model);
|
|
1909
|
+
const buffer = await this.readModelBlock(unitId, model);
|
|
1700
1910
|
// Extract scale factors from buffer (offsets 22-31)
|
|
1701
1911
|
const scaleFactors = {
|
|
1702
1912
|
WMax_SF: this.extractValue(buffer, 22, 'int16'),
|
|
@@ -1780,15 +1990,15 @@ export class SunspecModbusClient {
|
|
|
1780
1990
|
/**
|
|
1781
1991
|
* Read inverter controls from Model 123 (Immediate Inverter Controls)
|
|
1782
1992
|
*/
|
|
1783
|
-
async readInverterControls() {
|
|
1784
|
-
const model = this.findModel(SunspecModelId.Controls);
|
|
1993
|
+
async readInverterControls(unitId) {
|
|
1994
|
+
const model = this.findModel(unitId, SunspecModelId.Controls);
|
|
1785
1995
|
if (!model) {
|
|
1786
|
-
console.log(
|
|
1996
|
+
console.log(`Controls model 123 not found on unit ${unitId}`);
|
|
1787
1997
|
return null;
|
|
1788
1998
|
}
|
|
1789
1999
|
try {
|
|
1790
2000
|
// Read entire model block in a single Modbus call
|
|
1791
|
-
const buffer = await this.readModelBlock(model);
|
|
2001
|
+
const buffer = await this.readModelBlock(unitId, model);
|
|
1792
2002
|
// Extract scale factors from buffer (offsets 21-23)
|
|
1793
2003
|
const scaleFactors = {
|
|
1794
2004
|
WMaxLimPct_SF: this.extractValue(buffer, 21, 'int16'),
|
|
@@ -1875,27 +2085,28 @@ export class SunspecModbusClient {
|
|
|
1875
2085
|
/**
|
|
1876
2086
|
* Write Block 121 - Inverter Basic Settings
|
|
1877
2087
|
*/
|
|
1878
|
-
async writeInverterSettings(settings) {
|
|
1879
|
-
const model = this.findModel(SunspecModelId.Settings);
|
|
2088
|
+
async writeInverterSettings(unitId, settings) {
|
|
2089
|
+
const model = this.findModel(unitId, SunspecModelId.Settings);
|
|
1880
2090
|
if (!model) {
|
|
1881
|
-
console.error(
|
|
2091
|
+
console.error(`Settings model 121 not found on unit ${unitId}`);
|
|
1882
2092
|
return false;
|
|
1883
2093
|
}
|
|
1884
2094
|
const baseAddr = model.address;
|
|
1885
2095
|
try {
|
|
2096
|
+
const instance = this.getInstance(unitId);
|
|
1886
2097
|
// For each setting, write the value if provided
|
|
1887
2098
|
// Note: This is a simplified implementation. In production, you'd batch writes
|
|
1888
|
-
if (settings.WMax !== undefined
|
|
2099
|
+
if (settings.WMax !== undefined) {
|
|
1889
2100
|
// Need to read scale factor first if not provided
|
|
1890
|
-
const sfBuffer = await
|
|
2101
|
+
const sfBuffer = await instance.readHoldingRegisters(baseAddr + 22, 1);
|
|
1891
2102
|
const scaleFactor = sfBuffer.readInt16BE(0);
|
|
1892
2103
|
const scaledValue = Math.round(settings.WMax / Math.pow(10, scaleFactor));
|
|
1893
2104
|
// Writing registers needs to be implemented in EnergyAppModbusInstance
|
|
1894
2105
|
// For now, log the write operation
|
|
1895
2106
|
console.log(`Would write value ${scaledValue} to register ${baseAddr}`);
|
|
1896
2107
|
}
|
|
1897
|
-
if (settings.VRef !== undefined
|
|
1898
|
-
const sfBuffer = await
|
|
2108
|
+
if (settings.VRef !== undefined) {
|
|
2109
|
+
const sfBuffer = await instance.readHoldingRegisters(baseAddr + 23, 1);
|
|
1899
2110
|
const scaleFactor = sfBuffer.readInt16BE(0);
|
|
1900
2111
|
const scaledValue = Math.round(settings.VRef / Math.pow(10, scaleFactor));
|
|
1901
2112
|
console.log(`Would write value ${scaledValue} to register ${baseAddr + 1}`);
|
|
@@ -1912,41 +2123,41 @@ export class SunspecModbusClient {
|
|
|
1912
2123
|
/**
|
|
1913
2124
|
* Write inverter controls to Model 123 (Immediate Inverter Controls)
|
|
1914
2125
|
*/
|
|
1915
|
-
async writeInverterControls(controls) {
|
|
1916
|
-
const model = this.findModel(SunspecModelId.Controls);
|
|
2126
|
+
async writeInverterControls(unitId, controls) {
|
|
2127
|
+
const model = this.findModel(unitId, SunspecModelId.Controls);
|
|
1917
2128
|
if (!model) {
|
|
1918
|
-
console.error(
|
|
2129
|
+
console.error(`Controls model 123 not found on unit ${unitId}`);
|
|
1919
2130
|
return false;
|
|
1920
2131
|
}
|
|
1921
2132
|
const baseAddr = model.address;
|
|
1922
2133
|
try {
|
|
1923
2134
|
// Connection control (Register 2)
|
|
1924
2135
|
if (controls.Conn !== undefined) {
|
|
1925
|
-
await this.writeRegisterValue(baseAddr + 2, controls.Conn, 'uint16');
|
|
2136
|
+
await this.writeRegisterValue(unitId, baseAddr + 2, controls.Conn, 'uint16');
|
|
1926
2137
|
console.log(`Set connection control to ${controls.Conn}`);
|
|
1927
2138
|
}
|
|
1928
2139
|
// Power limit control (Register 3) - needs scale factor
|
|
1929
2140
|
if (controls.WMaxLimPct !== undefined) {
|
|
1930
|
-
const scaleFactor = await this.readRegisterValue(baseAddr + 21, 1, 'int16');
|
|
2141
|
+
const scaleFactor = await this.readRegisterValue(unitId, baseAddr + 21, 1, 'int16');
|
|
1931
2142
|
const scaledValue = Math.round(controls.WMaxLimPct / Math.pow(10, scaleFactor));
|
|
1932
|
-
await this.writeRegisterValue(baseAddr + 3, scaledValue, 'uint16');
|
|
2143
|
+
await this.writeRegisterValue(unitId, baseAddr + 3, scaledValue, 'uint16');
|
|
1933
2144
|
console.log(`Set power limit to ${controls.WMaxLimPct}% (scaled: ${scaledValue})`);
|
|
1934
2145
|
}
|
|
1935
2146
|
// Throttle enable/disable (Register 7)
|
|
1936
2147
|
if (controls.WMaxLim_Ena !== undefined) {
|
|
1937
|
-
await this.writeRegisterValue(baseAddr + 7, controls.WMaxLim_Ena, 'uint16');
|
|
2148
|
+
await this.writeRegisterValue(unitId, baseAddr + 7, controls.WMaxLim_Ena, 'uint16');
|
|
1938
2149
|
console.log(`Set throttle enable to ${controls.WMaxLim_Ena}`);
|
|
1939
2150
|
}
|
|
1940
2151
|
// Power factor control (Register 8) - needs scale factor
|
|
1941
2152
|
if (controls.OutPFSet !== undefined) {
|
|
1942
|
-
const scaleFactor = await this.readRegisterValue(baseAddr + 22, 1, 'int16');
|
|
2153
|
+
const scaleFactor = await this.readRegisterValue(unitId, baseAddr + 22, 1, 'int16');
|
|
1943
2154
|
const scaledValue = Math.round(controls.OutPFSet / Math.pow(10, scaleFactor));
|
|
1944
|
-
await this.writeRegisterValue(baseAddr + 8, scaledValue, 'int16');
|
|
2155
|
+
await this.writeRegisterValue(unitId, baseAddr + 8, scaledValue, 'int16');
|
|
1945
2156
|
console.log(`Set power factor to ${controls.OutPFSet} (scaled: ${scaledValue})`);
|
|
1946
2157
|
}
|
|
1947
2158
|
// Power factor enable/disable (Register 12)
|
|
1948
2159
|
if (controls.OutPFSet_Ena !== undefined) {
|
|
1949
|
-
await this.writeRegisterValue(baseAddr + 12, controls.OutPFSet_Ena, 'uint16');
|
|
2160
|
+
await this.writeRegisterValue(unitId, baseAddr + 12, controls.OutPFSet_Ena, 'uint16');
|
|
1950
2161
|
console.log(`Set PF enable to ${controls.OutPFSet_Ena}`);
|
|
1951
2162
|
}
|
|
1952
2163
|
console.log('Inverter controls written successfully');
|
|
@@ -1967,21 +2178,21 @@ export class SunspecModbusClient {
|
|
|
1967
2178
|
* @param limitW - Power limit in Watts, or null to remove the limit
|
|
1968
2179
|
* @returns true if successful, false otherwise
|
|
1969
2180
|
*/
|
|
1970
|
-
async setFeedInLimit(limitW) {
|
|
2181
|
+
async setFeedInLimit(unitId, limitW) {
|
|
1971
2182
|
if (limitW === null) {
|
|
1972
2183
|
// Remove limit: disable WMaxLim_Ena
|
|
1973
|
-
console.log(
|
|
1974
|
-
return this.writeInverterControls({ WMaxLim_Ena: SunspecEnableControl.DISABLED });
|
|
2184
|
+
console.log(`Removing feed-in limit (disabling WMaxLim_Ena) on unit ${unitId}`);
|
|
2185
|
+
return this.writeInverterControls(unitId, { WMaxLim_Ena: SunspecEnableControl.DISABLED });
|
|
1975
2186
|
}
|
|
1976
2187
|
// Read WMax from Model 121 to compute percentage
|
|
1977
|
-
const settings = await this.readInverterSettings();
|
|
2188
|
+
const settings = await this.readInverterSettings(unitId);
|
|
1978
2189
|
if (!settings || !settings.WMax) {
|
|
1979
|
-
console.error(
|
|
2190
|
+
console.error(`Cannot set feed-in limit on unit ${unitId}: unable to read WMax from Model 121`);
|
|
1980
2191
|
return false;
|
|
1981
2192
|
}
|
|
1982
2193
|
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({
|
|
2194
|
+
console.log(`Setting feed-in limit to ${limitW}W (${pct.toFixed(2)}% of WMax ${settings.WMax}W) on unit ${unitId}`);
|
|
2195
|
+
return this.writeInverterControls(unitId, {
|
|
1985
2196
|
WMaxLimPct: pct,
|
|
1986
2197
|
WMaxLim_Ena: SunspecEnableControl.ENABLED
|
|
1987
2198
|
});
|