@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
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.SunspecModbusClient = void 0;
|
|
4
|
+
exports.getOrCreateSunspecClient = getOrCreateSunspecClient;
|
|
5
|
+
exports.releaseSunspecClient = releaseSunspecClient;
|
|
4
6
|
/**
|
|
5
7
|
* Sunspec Modbus Client Implementation
|
|
6
8
|
*
|
|
@@ -23,21 +25,75 @@ const sunspec_interfaces_js_1 = require("./sunspec-interfaces.cjs");
|
|
|
23
25
|
const EnergyAppModbusConnectionHealth_js_1 = require("@enyo-energy/energy-app-sdk/dist/implementations/modbus/EnergyAppModbusConnectionHealth.js");
|
|
24
26
|
const EnergyAppModbusFaultTolerantReader_js_1 = require("@enyo-energy/energy-app-sdk/dist/implementations/modbus/EnergyAppModbusFaultTolerantReader.js");
|
|
25
27
|
const EnergyAppModbusDataTypeConverter_js_1 = require("@enyo-energy/energy-app-sdk/dist/implementations/modbus/EnergyAppModbusDataTypeConverter.js");
|
|
28
|
+
/**
|
|
29
|
+
* Module-level registry: one SunspecModbusClient per network device (host:port).
|
|
30
|
+
* Refcounted so the underlying connection only tears down when the last consumer releases.
|
|
31
|
+
*/
|
|
32
|
+
const CLIENT_REGISTRY = new Map();
|
|
33
|
+
const registryKey = (host, port) => `${host}:${port}`;
|
|
34
|
+
/**
|
|
35
|
+
* Get (or create) the singleton SunspecModbusClient for this network device.
|
|
36
|
+
* Increments the refcount; pair with `releaseSunspecClient` on teardown.
|
|
37
|
+
*/
|
|
38
|
+
function getOrCreateSunspecClient(energyApp, host, port = 502) {
|
|
39
|
+
const key = registryKey(host, port);
|
|
40
|
+
let entry = CLIENT_REGISTRY.get(key);
|
|
41
|
+
if (!entry) {
|
|
42
|
+
entry = { client: new SunspecModbusClient(energyApp), refcount: 0 };
|
|
43
|
+
CLIENT_REGISTRY.set(key, entry);
|
|
44
|
+
}
|
|
45
|
+
entry.refcount++;
|
|
46
|
+
return entry.client;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Release a refcount on the network-device client. When the last consumer releases,
|
|
50
|
+
* the client is fully disconnected (all units closed) and removed from the registry.
|
|
51
|
+
*
|
|
52
|
+
* Releasing more times than acquired is a programming error and is logged; the count
|
|
53
|
+
* is clamped at zero so repeat releases are no-ops.
|
|
54
|
+
*/
|
|
55
|
+
async function releaseSunspecClient(host, port = 502) {
|
|
56
|
+
const key = registryKey(host, port);
|
|
57
|
+
const entry = CLIENT_REGISTRY.get(key);
|
|
58
|
+
if (!entry)
|
|
59
|
+
return;
|
|
60
|
+
if (entry.refcount <= 0) {
|
|
61
|
+
console.warn(`releaseSunspecClient(${host}:${port}): refcount already 0 — extra release ignored`);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
entry.refcount--;
|
|
65
|
+
if (entry.refcount === 0) {
|
|
66
|
+
// Disconnect first, THEN remove from registry, so a concurrent getOrCreate
|
|
67
|
+
// doesn't bind a fresh client to the same host:port while sockets are still
|
|
68
|
+
// closing on the old one.
|
|
69
|
+
try {
|
|
70
|
+
await entry.client.disconnect();
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
// Guard against the very-unlikely case where someone re-acquired during await.
|
|
74
|
+
if (CLIENT_REGISTRY.get(key) === entry && entry.refcount === 0) {
|
|
75
|
+
CLIENT_REGISTRY.delete(key);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
26
80
|
class SunspecModbusClient {
|
|
27
81
|
energyApp;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
82
|
+
// Per-unit Modbus instance/reader/discovered-models maps. One client = one network device,
|
|
83
|
+
// many unit IDs share that client (each unit ID is one EnergyAppModbusInstance underneath).
|
|
84
|
+
modbusInstances = new Map();
|
|
85
|
+
faultTolerantReaders = new Map();
|
|
86
|
+
discoveredModelsByUnit = new Map();
|
|
31
87
|
connectionHealth;
|
|
32
|
-
faultTolerantReader = null;
|
|
33
88
|
modbusDataTypeConverter;
|
|
34
89
|
connectionParams = null;
|
|
90
|
+
// Per-unit known parameters so reconnect() can replay every unit ID we've seen.
|
|
91
|
+
knownUnits = new Set();
|
|
35
92
|
autoReconnectEnabled = true;
|
|
36
93
|
openCount = 0;
|
|
37
94
|
closeCount = 0;
|
|
38
|
-
|
|
39
|
-
//
|
|
40
|
-
// TCP socket while the first is still alive (some Modbus devices allow only one client).
|
|
95
|
+
// Serializes connection-state transitions so concurrent callers cannot open duplicate
|
|
96
|
+
// sockets for the same unit ID while one is still alive.
|
|
41
97
|
operationChain = Promise.resolve();
|
|
42
98
|
constructor(energyApp) {
|
|
43
99
|
this.energyApp = energyApp;
|
|
@@ -45,7 +101,10 @@ class SunspecModbusClient {
|
|
|
45
101
|
this.modbusDataTypeConverter = new EnergyAppModbusDataTypeConverter_js_1.EnergyAppModbusDataTypeConverter();
|
|
46
102
|
}
|
|
47
103
|
/**
|
|
48
|
-
* Connect to Modbus device
|
|
104
|
+
* Connect to a Modbus device unit. Multiple unit IDs on the same network device share this
|
|
105
|
+
* client; calling connect() with different unit IDs adds each as a separately-managed unit.
|
|
106
|
+
* The first call locks in the host/port for this client; later calls for a different host
|
|
107
|
+
* are rejected.
|
|
49
108
|
* @param host Primary host (hostname) to connect to
|
|
50
109
|
* @param port Modbus port (default 502)
|
|
51
110
|
* @param unitId Modbus unit ID (default 1)
|
|
@@ -53,45 +112,64 @@ class SunspecModbusClient {
|
|
|
53
112
|
*/
|
|
54
113
|
async connect(host, port = 502, unitId = 1, secondaryHost) {
|
|
55
114
|
return this.withConnectionLock(async () => {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
115
|
+
if (this.connectionParams) {
|
|
116
|
+
if (this.connectionParams.primaryHost !== host || this.connectionParams.port !== port) {
|
|
117
|
+
throw new Error(`SunspecModbusClient is bound to ${this.connectionParams.primaryHost}:${this.connectionParams.port}; ` +
|
|
118
|
+
`cannot reuse it for ${host}:${port}. Use getOrCreateSunspecClient() per (host, port).`);
|
|
119
|
+
}
|
|
120
|
+
if (secondaryHost && !this.connectionParams.secondaryHost) {
|
|
121
|
+
this.connectionParams.secondaryHost = secondaryHost;
|
|
122
|
+
}
|
|
64
123
|
}
|
|
65
|
-
|
|
66
|
-
|
|
124
|
+
else {
|
|
125
|
+
this.connectionParams = { primaryHost: host, secondaryHost, port };
|
|
67
126
|
}
|
|
68
|
-
this.
|
|
69
|
-
|
|
70
|
-
|
|
127
|
+
this.knownUnits.add(unitId);
|
|
128
|
+
if (this.modbusInstances.has(unitId)) {
|
|
129
|
+
console.debug(`connect(): unit ${unitId} on ${host}:${port} already open, skipping`);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
await this._openUnit(host, port, unitId);
|
|
133
|
+
console.log(`Connected to Sunspec device at ${host}:${port} unit ${unitId} (opens=${this.openCount}, closes=${this.closeCount}, openUnits=${this.modbusInstances.size})`);
|
|
71
134
|
});
|
|
72
135
|
}
|
|
73
136
|
/**
|
|
74
|
-
* Disconnect from
|
|
137
|
+
* Disconnect from all units of this network device.
|
|
75
138
|
*
|
|
76
|
-
* Note: connection parameters are preserved so reconnect() can
|
|
77
|
-
* They will be overwritten by the next connect() call anyway.
|
|
139
|
+
* Note: connection parameters and the set of known units are preserved so reconnect() can
|
|
140
|
+
* be called afterwards. They will be overwritten by the next connect() call anyway.
|
|
78
141
|
*/
|
|
79
142
|
async disconnect() {
|
|
80
143
|
return this.withConnectionLock(async () => {
|
|
81
|
-
if (
|
|
144
|
+
if (this.modbusInstances.size === 0) {
|
|
82
145
|
return;
|
|
83
146
|
}
|
|
84
147
|
const host = this.connectionParams?.primaryHost ?? 'unknown';
|
|
85
148
|
const port = this.connectionParams?.port ?? 0;
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
149
|
+
const units = [...this.modbusInstances.keys()];
|
|
150
|
+
for (const unitId of units) {
|
|
151
|
+
await this._closeUnit(unitId);
|
|
152
|
+
}
|
|
153
|
+
console.log(`Disconnected from Sunspec device at ${host}:${port} units [${units.join(',')}] (opens=${this.openCount}, closes=${this.closeCount})`);
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Disconnect a single unit on this network device. Other units on the same device stay open.
|
|
158
|
+
*/
|
|
159
|
+
async disconnectUnit(unitId) {
|
|
160
|
+
return this.withConnectionLock(async () => {
|
|
161
|
+
if (!this.modbusInstances.has(unitId))
|
|
162
|
+
return;
|
|
163
|
+
const host = this.connectionParams?.primaryHost ?? 'unknown';
|
|
164
|
+
const port = this.connectionParams?.port ?? 0;
|
|
165
|
+
await this._closeUnit(unitId);
|
|
166
|
+
console.log(`Disconnected unit ${unitId} from Sunspec device at ${host}:${port} (opens=${this.openCount}, closes=${this.closeCount})`);
|
|
89
167
|
});
|
|
90
168
|
}
|
|
91
169
|
/**
|
|
92
|
-
* Reconnect using stored connection
|
|
93
|
-
* First tries primaryHost (hostname), then falls back to secondaryHost
|
|
94
|
-
* Returns true if
|
|
170
|
+
* Reconnect every previously-known unit on this network device using stored connection
|
|
171
|
+
* parameters. First tries primaryHost (hostname), then falls back to secondaryHost
|
|
172
|
+
* (ipAddress) if available. Returns true only if every known unit reconnected successfully.
|
|
95
173
|
*/
|
|
96
174
|
async reconnect() {
|
|
97
175
|
return this.withConnectionLock(async () => {
|
|
@@ -99,18 +177,23 @@ class SunspecModbusClient {
|
|
|
99
177
|
console.error('Cannot reconnect: no connection parameters stored. Call connect() first.');
|
|
100
178
|
return false;
|
|
101
179
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
180
|
+
if (this.knownUnits.size === 0) {
|
|
181
|
+
console.error('Cannot reconnect: no known unit IDs. Call connect(unitId) at least once first.');
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
const { primaryHost, secondaryHost, port } = this.connectionParams;
|
|
185
|
+
const units = [...this.knownUnits];
|
|
186
|
+
console.log(`Attempting to reconnect to primary host ${primaryHost}:${port} units [${units.join(',')}]...`);
|
|
187
|
+
const primarySuccess = await this.attemptConnection(primaryHost, port, units);
|
|
105
188
|
if (primarySuccess) {
|
|
106
|
-
console.log(`Successfully reconnected to primary host ${primaryHost}:${port}
|
|
189
|
+
console.log(`Successfully reconnected to primary host ${primaryHost}:${port} units [${units.join(',')}]`);
|
|
107
190
|
return true;
|
|
108
191
|
}
|
|
109
192
|
if (secondaryHost && secondaryHost !== primaryHost) {
|
|
110
|
-
console.log(`Primary host failed, attempting secondary host ${secondaryHost}:${port}
|
|
111
|
-
const secondarySuccess = await this.attemptConnection(secondaryHost, port,
|
|
193
|
+
console.log(`Primary host failed, attempting secondary host ${secondaryHost}:${port} units [${units.join(',')}]...`);
|
|
194
|
+
const secondarySuccess = await this.attemptConnection(secondaryHost, port, units);
|
|
112
195
|
if (secondarySuccess) {
|
|
113
|
-
console.log(`Successfully reconnected to secondary host ${secondaryHost}:${port}
|
|
196
|
+
console.log(`Successfully reconnected to secondary host ${secondaryHost}:${port} units [${units.join(',')}]`);
|
|
114
197
|
return true;
|
|
115
198
|
}
|
|
116
199
|
}
|
|
@@ -119,16 +202,53 @@ class SunspecModbusClient {
|
|
|
119
202
|
});
|
|
120
203
|
}
|
|
121
204
|
/**
|
|
122
|
-
*
|
|
123
|
-
*
|
|
205
|
+
* Reconnect a single previously-known unit on this network device. Other units on the
|
|
206
|
+
* same client stay open. Useful when one device's polling loop detects a dropped
|
|
207
|
+
* connection and only needs to recover its own unit, not thrash siblings.
|
|
208
|
+
*
|
|
209
|
+
* Returns true if the unit reconnected successfully (on primary or secondary host).
|
|
210
|
+
*/
|
|
211
|
+
async reconnectUnit(unitId) {
|
|
212
|
+
return this.withConnectionLock(async () => {
|
|
213
|
+
if (!this.connectionParams) {
|
|
214
|
+
console.error(`Cannot reconnectUnit(${unitId}): no connection parameters stored. Call connect() first.`);
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
const { primaryHost, secondaryHost, port } = this.connectionParams;
|
|
218
|
+
console.log(`Attempting to reconnect unit ${unitId} on primary host ${primaryHost}:${port}...`);
|
|
219
|
+
const primarySuccess = await this.attemptConnection(primaryHost, port, [unitId]);
|
|
220
|
+
if (primarySuccess) {
|
|
221
|
+
console.log(`Successfully reconnected unit ${unitId} on primary host ${primaryHost}:${port}`);
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
if (secondaryHost && secondaryHost !== primaryHost) {
|
|
225
|
+
console.log(`Primary host failed for unit ${unitId}, attempting secondary host ${secondaryHost}:${port}...`);
|
|
226
|
+
const secondarySuccess = await this.attemptConnection(secondaryHost, port, [unitId]);
|
|
227
|
+
if (secondarySuccess) {
|
|
228
|
+
console.log(`Successfully reconnected unit ${unitId} on secondary host ${secondaryHost}:${port}`);
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
console.error(`Reconnection failed for unit ${unitId} on all available hosts`);
|
|
233
|
+
return false;
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Attempt to (re)open every requested unit on a specific host. Caller must hold the
|
|
238
|
+
* connection lock. Closes any pre-existing per-unit instances first. Returns true only
|
|
239
|
+
* if every unit succeeded.
|
|
124
240
|
*/
|
|
125
|
-
async attemptConnection(host, port,
|
|
126
|
-
|
|
127
|
-
|
|
241
|
+
async attemptConnection(host, port, units) {
|
|
242
|
+
for (const unitId of units) {
|
|
243
|
+
if (this.modbusInstances.has(unitId)) {
|
|
244
|
+
await this._closeUnit(unitId);
|
|
245
|
+
}
|
|
128
246
|
}
|
|
129
247
|
try {
|
|
130
|
-
|
|
131
|
-
|
|
248
|
+
for (const unitId of units) {
|
|
249
|
+
await this._openUnit(host, port, unitId);
|
|
250
|
+
}
|
|
251
|
+
console.log(`Connection attempt to ${host}:${port} units [${units.join(',')}] succeeded (opens=${this.openCount}, closes=${this.closeCount}, openUnits=${this.modbusInstances.size})`);
|
|
132
252
|
return true;
|
|
133
253
|
}
|
|
134
254
|
catch (error) {
|
|
@@ -137,11 +257,11 @@ class SunspecModbusClient {
|
|
|
137
257
|
}
|
|
138
258
|
}
|
|
139
259
|
/**
|
|
140
|
-
* Open a new Modbus
|
|
141
|
-
* just-opened
|
|
142
|
-
* lock and ensure
|
|
260
|
+
* Open a new Modbus instance for one unit ID and wire up its fault-tolerant reader.
|
|
261
|
+
* On any failure, the just-opened instance (if any) is closed so we never leak. Caller must
|
|
262
|
+
* hold the connection lock and ensure the unit is not already open.
|
|
143
263
|
*/
|
|
144
|
-
async
|
|
264
|
+
async _openUnit(host, port, unitId) {
|
|
145
265
|
let candidate = null;
|
|
146
266
|
try {
|
|
147
267
|
candidate = await this.energyApp.useModbus().connect({
|
|
@@ -153,9 +273,9 @@ class SunspecModbusClient {
|
|
|
153
273
|
if (!candidate) {
|
|
154
274
|
throw new Error(`useModbus().connect returned null for ${host}:${port} unit ${unitId}`);
|
|
155
275
|
}
|
|
156
|
-
|
|
157
|
-
this.
|
|
158
|
-
this.
|
|
276
|
+
const reader = new EnergyAppModbusFaultTolerantReader_js_1.EnergyAppModbusFaultTolerantReader(candidate, this.connectionHealth);
|
|
277
|
+
this.modbusInstances.set(unitId, candidate);
|
|
278
|
+
this.faultTolerantReaders.set(unitId, reader);
|
|
159
279
|
this.connectionHealth.recordSuccess();
|
|
160
280
|
this.recordOpen();
|
|
161
281
|
}
|
|
@@ -166,31 +286,28 @@ class SunspecModbusClient {
|
|
|
166
286
|
}
|
|
167
287
|
catch { /* ignore */ }
|
|
168
288
|
}
|
|
169
|
-
this.
|
|
170
|
-
this.
|
|
171
|
-
this.connected = false;
|
|
289
|
+
this.modbusInstances.delete(unitId);
|
|
290
|
+
this.faultTolerantReaders.delete(unitId);
|
|
172
291
|
throw err;
|
|
173
292
|
}
|
|
174
293
|
}
|
|
175
294
|
/**
|
|
176
|
-
* Close the
|
|
295
|
+
* Close the Modbus instance for a single unit. Idempotent. Caller must hold the
|
|
296
|
+
* connection lock.
|
|
177
297
|
*/
|
|
178
|
-
async
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
this.
|
|
184
|
-
|
|
185
|
-
this.connected = false;
|
|
186
|
-
this.discoveredModels.clear();
|
|
187
|
-
if (client) {
|
|
298
|
+
async _closeUnit(unitId) {
|
|
299
|
+
const instance = this.modbusInstances.get(unitId);
|
|
300
|
+
const wasOpen = instance !== undefined;
|
|
301
|
+
this.modbusInstances.delete(unitId);
|
|
302
|
+
this.faultTolerantReaders.delete(unitId);
|
|
303
|
+
this.discoveredModelsByUnit.delete(unitId);
|
|
304
|
+
if (instance) {
|
|
188
305
|
try {
|
|
189
|
-
await
|
|
306
|
+
await instance.disconnect();
|
|
190
307
|
}
|
|
191
308
|
catch { /* ignore */ }
|
|
192
309
|
}
|
|
193
|
-
if (
|
|
310
|
+
if (wasOpen)
|
|
194
311
|
this.recordClose();
|
|
195
312
|
}
|
|
196
313
|
/**
|
|
@@ -204,27 +321,55 @@ class SunspecModbusClient {
|
|
|
204
321
|
}
|
|
205
322
|
recordOpen() {
|
|
206
323
|
this.openCount++;
|
|
207
|
-
this.
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
324
|
+
const expected = this.modbusInstances.size;
|
|
325
|
+
const delta = this.openCount - this.closeCount;
|
|
326
|
+
if (delta > expected) {
|
|
327
|
+
console.error(`SunspecModbusClient: open/close imbalance — ` +
|
|
328
|
+
`opens=${this.openCount}, closes=${this.closeCount}, instances=${expected}. ` +
|
|
211
329
|
`This indicates a code path bypassing the connection lock — please investigate.`);
|
|
212
330
|
}
|
|
213
331
|
}
|
|
214
332
|
recordClose() {
|
|
215
333
|
this.closeCount++;
|
|
216
|
-
if (this.
|
|
217
|
-
this.
|
|
218
|
-
}
|
|
219
|
-
else {
|
|
220
|
-
console.warn(`SunspecModbusClient: closeCount incremented while currentlyOpen was 0 — possible double-close`);
|
|
334
|
+
if (this.closeCount > this.openCount) {
|
|
335
|
+
console.warn(`SunspecModbusClient: closeCount=${this.closeCount} exceeded openCount=${this.openCount} — possible double-close`);
|
|
221
336
|
}
|
|
222
337
|
}
|
|
223
338
|
/**
|
|
224
|
-
* Get cumulative open/close counts for this client. Useful for
|
|
339
|
+
* Get cumulative open/close counts for this client (across all unit IDs). Useful for
|
|
340
|
+
* spotting connection leaks.
|
|
225
341
|
*/
|
|
226
342
|
getConnectionStats() {
|
|
227
|
-
return { opens: this.openCount, closes: this.closeCount,
|
|
343
|
+
return { opens: this.openCount, closes: this.closeCount, openUnits: this.modbusInstances.size };
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Get the EnergyAppModbusInstance for a unit, throwing if it isn't open.
|
|
347
|
+
*/
|
|
348
|
+
getInstance(unitId) {
|
|
349
|
+
const inst = this.modbusInstances.get(unitId);
|
|
350
|
+
if (!inst)
|
|
351
|
+
throw new Error(`Modbus instance for unit ${unitId} not initialized — call connect(host, port, ${unitId}) first`);
|
|
352
|
+
return inst;
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Get the fault-tolerant reader for a unit, throwing if it isn't open.
|
|
356
|
+
*/
|
|
357
|
+
getReader(unitId) {
|
|
358
|
+
const reader = this.faultTolerantReaders.get(unitId);
|
|
359
|
+
if (!reader)
|
|
360
|
+
throw new Error(`Fault-tolerant reader for unit ${unitId} not initialized — call connect(host, port, ${unitId}) first`);
|
|
361
|
+
return reader;
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Get (or create) the discovered-models map for a unit.
|
|
365
|
+
*/
|
|
366
|
+
getModelsMap(unitId) {
|
|
367
|
+
let m = this.discoveredModelsByUnit.get(unitId);
|
|
368
|
+
if (!m) {
|
|
369
|
+
m = new Map();
|
|
370
|
+
this.discoveredModelsByUnit.set(unitId, m);
|
|
371
|
+
}
|
|
372
|
+
return m;
|
|
228
373
|
}
|
|
229
374
|
/**
|
|
230
375
|
* Enable or disable automatic reconnection
|
|
@@ -242,16 +387,14 @@ class SunspecModbusClient {
|
|
|
242
387
|
/**
|
|
243
388
|
* Detect the base address and addressing mode (0-based or 1-based) for SunSpec
|
|
244
389
|
*/
|
|
245
|
-
async detectSunspecBaseAddress(customBaseAddress) {
|
|
246
|
-
|
|
247
|
-
throw new Error('Modbus client not initialized');
|
|
248
|
-
}
|
|
390
|
+
async detectSunspecBaseAddress(unitId, customBaseAddress) {
|
|
391
|
+
const instance = this.getInstance(unitId);
|
|
249
392
|
// If custom base address provided, try it first (1-based, then 0-based variant)
|
|
250
393
|
if (customBaseAddress !== undefined) {
|
|
251
394
|
console.log(`Detect models for custom base address '${customBaseAddress}' ...`);
|
|
252
395
|
// Try 0-based at custom address
|
|
253
396
|
try {
|
|
254
|
-
const sunspecId = await
|
|
397
|
+
const sunspecId = await instance.readRegisterStringValue(customBaseAddress, 2);
|
|
255
398
|
if (sunspecId.includes('SunS')) {
|
|
256
399
|
console.log(`Detected 0-based addressing mode (base address: ${customBaseAddress})`);
|
|
257
400
|
return {
|
|
@@ -266,7 +409,7 @@ class SunspecModbusClient {
|
|
|
266
409
|
}
|
|
267
410
|
// Try 1-based at custom address (customBaseAddress + 1)
|
|
268
411
|
try {
|
|
269
|
-
const sunspecId = await
|
|
412
|
+
const sunspecId = await instance.readRegisterStringValue(customBaseAddress + 1, 2);
|
|
270
413
|
if (sunspecId.includes('SunS')) {
|
|
271
414
|
console.log(`Detected 1-based addressing mode (base address: ${customBaseAddress + 1})`);
|
|
272
415
|
return {
|
|
@@ -283,7 +426,7 @@ class SunspecModbusClient {
|
|
|
283
426
|
else {
|
|
284
427
|
// Try 1-based addressing first (most common)
|
|
285
428
|
try {
|
|
286
|
-
const sunspecId = await
|
|
429
|
+
const sunspecId = await instance.readRegisterStringValue(40001, 2);
|
|
287
430
|
if (sunspecId.includes('SunS')) {
|
|
288
431
|
console.log('Detected 1-based addressing mode (base address: 40001)');
|
|
289
432
|
return {
|
|
@@ -298,7 +441,7 @@ class SunspecModbusClient {
|
|
|
298
441
|
}
|
|
299
442
|
// Try 0-based addressing
|
|
300
443
|
try {
|
|
301
|
-
const sunspecId = await
|
|
444
|
+
const sunspecId = await instance.readRegisterStringValue(40000, 2);
|
|
302
445
|
if (sunspecId.includes('SunS')) {
|
|
303
446
|
console.log('Detected 0-based addressing mode (base address: 40000)');
|
|
304
447
|
return {
|
|
@@ -318,27 +461,22 @@ class SunspecModbusClient {
|
|
|
318
461
|
throw new Error(`Device is not SunSpec compliant - "SunS" identifier not found at addresses ${addressesChecked}`);
|
|
319
462
|
}
|
|
320
463
|
/**
|
|
321
|
-
* Discover all available Sunspec models
|
|
464
|
+
* Discover all available Sunspec models for a unit
|
|
322
465
|
* Automatically detects base address (40000 or 40001) and scans from there
|
|
323
466
|
*/
|
|
324
|
-
async discoverModels(customBaseAddress) {
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
this.discoveredModels.clear();
|
|
467
|
+
async discoverModels(unitId, customBaseAddress) {
|
|
468
|
+
const instance = this.getInstance(unitId);
|
|
469
|
+
const models = this.getModelsMap(unitId);
|
|
470
|
+
models.clear();
|
|
329
471
|
const maxAddress = 50000; // Safety limit
|
|
330
472
|
let currentAddress = 0;
|
|
331
|
-
console.log(
|
|
473
|
+
console.log(`Starting Sunspec model discovery for unit ${unitId}...`);
|
|
332
474
|
try {
|
|
333
475
|
// Detect the base address and addressing mode
|
|
334
|
-
const addressInfo = await this.detectSunspecBaseAddress(customBaseAddress);
|
|
476
|
+
const addressInfo = await this.detectSunspecBaseAddress(unitId, customBaseAddress);
|
|
335
477
|
currentAddress = addressInfo.nextAddress;
|
|
336
478
|
while (currentAddress < maxAddress) {
|
|
337
|
-
|
|
338
|
-
if (!this.modbusClient) {
|
|
339
|
-
throw new Error('Modbus client not initialized');
|
|
340
|
-
}
|
|
341
|
-
const buffer = await this.modbusClient.readHoldingRegisters(currentAddress, 2);
|
|
479
|
+
const buffer = await instance.readHoldingRegisters(currentAddress, 2);
|
|
342
480
|
const modelData = [buffer.readUInt16BE(0), buffer.readUInt16BE(2)];
|
|
343
481
|
if (!modelData || modelData.length < 2) {
|
|
344
482
|
console.log(`No data at address ${currentAddress}, ending discovery`);
|
|
@@ -357,23 +495,23 @@ class SunspecModbusClient {
|
|
|
357
495
|
address: currentAddress,
|
|
358
496
|
length: modelLength
|
|
359
497
|
};
|
|
360
|
-
|
|
361
|
-
console.log(`Discovered Model ${modelId} at address ${currentAddress} with length ${modelLength}`);
|
|
498
|
+
models.set(modelId, model);
|
|
499
|
+
console.log(`Discovered Model ${modelId} at address ${currentAddress} with length ${modelLength} (unit ${unitId})`);
|
|
362
500
|
// Jump to next model: current address + 2 (header) + model length
|
|
363
501
|
currentAddress = currentAddress + 2 + modelLength;
|
|
364
502
|
}
|
|
365
503
|
}
|
|
366
504
|
catch (error) {
|
|
367
|
-
console.error(`Error during model discovery at address ${currentAddress}: ${error}`);
|
|
505
|
+
console.error(`Error during model discovery at address ${currentAddress} (unit ${unitId}): ${error}`);
|
|
368
506
|
}
|
|
369
|
-
console.log(`Discovery complete. Found ${
|
|
370
|
-
return
|
|
507
|
+
console.log(`Discovery complete for unit ${unitId}. Found ${models.size} models: [${[...models.keys()].sort((a, b) => a - b).join(', ')}]`);
|
|
508
|
+
return models;
|
|
371
509
|
}
|
|
372
510
|
/**
|
|
373
|
-
* Find a specific model by ID
|
|
511
|
+
* Find a specific model by ID for a given unit
|
|
374
512
|
*/
|
|
375
|
-
findModel(modelId) {
|
|
376
|
-
return this.
|
|
513
|
+
findModel(unitId, modelId) {
|
|
514
|
+
return this.discoveredModelsByUnit.get(unitId)?.get(modelId);
|
|
377
515
|
}
|
|
378
516
|
/**
|
|
379
517
|
* Check if a value is "unimplemented" according to Sunspec specification
|
|
@@ -449,17 +587,15 @@ class SunspecModbusClient {
|
|
|
449
587
|
* Read an entire model's register block in a single Modbus call.
|
|
450
588
|
* Returns a Buffer containing all registers for the model.
|
|
451
589
|
*/
|
|
452
|
-
async readModelBlock(model) {
|
|
453
|
-
|
|
454
|
-
throw new Error('Fault-tolerant reader not initialized');
|
|
455
|
-
}
|
|
590
|
+
async readModelBlock(unitId, model) {
|
|
591
|
+
const reader = this.getReader(unitId);
|
|
456
592
|
// Read model.length + 2 registers: the 2-register header (ID + length) plus all data registers.
|
|
457
593
|
// This way buffer offsets match the convention used throughout: offset 0 = model ID,
|
|
458
594
|
// offset 1 = model length, offset 2 = first data register, etc.
|
|
459
595
|
const totalRegisters = model.length + 2;
|
|
460
|
-
const result = await
|
|
596
|
+
const result = await reader.readHoldingRegisters(model.address, totalRegisters);
|
|
461
597
|
if (!result.success || !result.value) {
|
|
462
|
-
throw new Error(`Failed to read model block ${model.id} at address ${model.address}: ${result.error?.message || 'Unknown error'}`);
|
|
598
|
+
throw new Error(`Failed to read model block ${model.id} at address ${model.address} (unit ${unitId}): ${result.error?.message || 'Unknown error'}`);
|
|
463
599
|
}
|
|
464
600
|
this.connectionHealth.recordSuccess();
|
|
465
601
|
return result.value;
|
|
@@ -484,12 +620,10 @@ class SunspecModbusClient {
|
|
|
484
620
|
/**
|
|
485
621
|
* Helper to read register value(s) using the fault-tolerant reader with data type conversion
|
|
486
622
|
*/
|
|
487
|
-
async readRegisterValue(address, quantity = 1, dataType) {
|
|
488
|
-
|
|
489
|
-
throw new Error('Fault-tolerant reader not initialized');
|
|
490
|
-
}
|
|
623
|
+
async readRegisterValue(unitId, address, quantity = 1, dataType) {
|
|
624
|
+
const reader = this.getReader(unitId);
|
|
491
625
|
try {
|
|
492
|
-
const result = await
|
|
626
|
+
const result = await reader.readHoldingRegisters(address, quantity);
|
|
493
627
|
// Check if the read was successful
|
|
494
628
|
if (!result.success || !result.value) {
|
|
495
629
|
throw new Error(`Failed to read register at address ${address}: ${result.error?.message || 'Unknown error'}`);
|
|
@@ -510,10 +644,8 @@ class SunspecModbusClient {
|
|
|
510
644
|
/**
|
|
511
645
|
* Helper to write register value(s)
|
|
512
646
|
*/
|
|
513
|
-
async writeRegisterValue(address, value, dataType = 'uint16') {
|
|
514
|
-
|
|
515
|
-
throw new Error('Modbus client not initialized');
|
|
516
|
-
}
|
|
647
|
+
async writeRegisterValue(unitId, address, value, dataType = 'uint16') {
|
|
648
|
+
const instance = this.getInstance(unitId);
|
|
517
649
|
try {
|
|
518
650
|
// Convert value to array of register values
|
|
519
651
|
let registerValues;
|
|
@@ -542,9 +674,9 @@ class SunspecModbusClient {
|
|
|
542
674
|
}
|
|
543
675
|
}
|
|
544
676
|
// Write to holding registers
|
|
545
|
-
await
|
|
677
|
+
await instance.writeMultipleRegisters(address, registerValues);
|
|
546
678
|
this.connectionHealth.recordSuccess();
|
|
547
|
-
console.log(`Successfully wrote value ${value} to register ${address}`);
|
|
679
|
+
console.log(`Successfully wrote value ${value} to register ${address} (unit ${unitId})`);
|
|
548
680
|
return true;
|
|
549
681
|
}
|
|
550
682
|
catch (error) {
|
|
@@ -554,28 +686,36 @@ class SunspecModbusClient {
|
|
|
554
686
|
}
|
|
555
687
|
}
|
|
556
688
|
/**
|
|
557
|
-
* Read inverter data
|
|
689
|
+
* Read inverter data. Detects which SunSpec inverter model the device exposes —
|
|
690
|
+
* int+SF (101/103) or float (111/112/113) — by checking the discovered model directory,
|
|
691
|
+
* and dispatches to the appropriate decoder.
|
|
558
692
|
*/
|
|
559
|
-
async readInverterData() {
|
|
560
|
-
const
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
693
|
+
async readInverterData(unitId) {
|
|
694
|
+
const tryOrder = [
|
|
695
|
+
{ id: 103, reader: m => this.readThreePhaseInverterData_IntSF(unitId, m) },
|
|
696
|
+
{ id: 113, reader: m => this.readFloatInverterData(unitId, m, 113) },
|
|
697
|
+
{ id: 112, reader: m => this.readFloatInverterData(unitId, m, 112) },
|
|
698
|
+
{ id: 101, reader: m => this.readSinglePhaseInverterData(unitId, m) },
|
|
699
|
+
{ id: 111, reader: m => this.readFloatInverterData(unitId, m, 111) },
|
|
700
|
+
];
|
|
701
|
+
for (const { id, reader } of tryOrder) {
|
|
702
|
+
const model = this.findModel(unitId, id);
|
|
703
|
+
if (model) {
|
|
704
|
+
console.debug(`Using inverter Model ${id} at address ${model.address} (length ${model.length}) on unit ${unitId}`);
|
|
705
|
+
return reader(model);
|
|
567
706
|
}
|
|
568
|
-
console.warn('IMPORTANT: Working with single-phase inverter, but 3-phase is expected!');
|
|
569
|
-
return this.readSinglePhaseInverterData(singlePhaseModel);
|
|
570
707
|
}
|
|
571
|
-
console.debug(`
|
|
708
|
+
console.debug(`No inverter model (101/103/111/112/113) on unit ${unitId}`);
|
|
709
|
+
return null;
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* Read three-phase inverter data from Model 103 (int + scale factor encoding).
|
|
713
|
+
*/
|
|
714
|
+
async readThreePhaseInverterData_IntSF(unitId, model) {
|
|
572
715
|
try {
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
const buffer = await this.readModelBlock(model);
|
|
576
|
-
// Extract all scale factors from buffer
|
|
716
|
+
console.debug(`Reading Inverter Data from Model 103 at base address: ${model.address} (unit ${unitId})`);
|
|
717
|
+
const buffer = await this.readModelBlock(unitId, model);
|
|
577
718
|
const scaleFactors = this.extractInverterScaleFactors(buffer);
|
|
578
|
-
// Extract raw values from buffer
|
|
579
719
|
const acCurrentRaw = this.extractValue(buffer, 2, 'uint16');
|
|
580
720
|
const voltageANRaw = this.extractValue(buffer, 10, 'uint16');
|
|
581
721
|
const voltageBNRaw = this.extractValue(buffer, 11, 'uint16');
|
|
@@ -627,7 +767,6 @@ class SunspecModbusClient {
|
|
|
627
767
|
vendorEvents3: this.extractValue(buffer, 48, 'uint32', 2),
|
|
628
768
|
vendorEvents4: this.extractValue(buffer, 50, 'uint32', 2)
|
|
629
769
|
};
|
|
630
|
-
// Log non-scaled fields
|
|
631
770
|
this.logRegisterRead(103, 38, 'Operating State', data.operatingState, 'enum16');
|
|
632
771
|
this.logRegisterRead(103, 39, 'Vendor State', data.vendorState, 'uint16');
|
|
633
772
|
this.logRegisterRead(103, 40, 'Events', data.events, 'bitfield32');
|
|
@@ -636,7 +775,6 @@ class SunspecModbusClient {
|
|
|
636
775
|
this.logRegisterRead(103, 46, 'Vendor Events 2', data.vendorEvents2, 'bitfield32');
|
|
637
776
|
this.logRegisterRead(103, 48, 'Vendor Events 3', data.vendorEvents3, 'bitfield32');
|
|
638
777
|
this.logRegisterRead(103, 50, 'Vendor Events 4', data.vendorEvents4, 'bitfield32');
|
|
639
|
-
// Read AC Energy (32-bit accumulator) - Offset 24-25
|
|
640
778
|
const acEnergy = this.extractValue(buffer, 24, 'uint32', 2);
|
|
641
779
|
this.logRegisterRead(103, 24, 'AC Energy', acEnergy, 'acc32');
|
|
642
780
|
data.acEnergy = !this.isUnimplementedValue(acEnergy, 'acc32')
|
|
@@ -653,49 +791,49 @@ class SunspecModbusClient {
|
|
|
653
791
|
/**
|
|
654
792
|
* Read single phase inverter data (Model 101)
|
|
655
793
|
*/
|
|
656
|
-
async readSinglePhaseInverterData(model) {
|
|
794
|
+
async readSinglePhaseInverterData(unitId, model) {
|
|
657
795
|
try {
|
|
658
|
-
console.debug(`Reading Single-Phase Inverter Data from Model 101 at base address: ${model.address}`);
|
|
796
|
+
console.debug(`Reading Single-Phase Inverter Data from Model 101 at base address: ${model.address} (unit ${unitId})`);
|
|
659
797
|
// Read entire model block in a single Modbus call
|
|
660
|
-
const buffer = await this.readModelBlock(model);
|
|
661
|
-
// Extract scale factors from buffer
|
|
798
|
+
const buffer = await this.readModelBlock(unitId, model);
|
|
799
|
+
// Extract scale factors from buffer (offsets aligned with Model 103 reader, which works on real
|
|
800
|
+
// hardware; differs from the published SunSpec Model 101 spec for DC fields).
|
|
662
801
|
const scaleFactors = {
|
|
663
802
|
A_SF: this.extractValue(buffer, 6, 'int16'),
|
|
664
803
|
V_SF: this.extractValue(buffer, 13, 'int16'),
|
|
665
|
-
W_SF: this.extractValue(buffer,
|
|
666
|
-
Hz_SF: this.extractValue(buffer,
|
|
804
|
+
W_SF: this.extractValue(buffer, 15, 'int16'),
|
|
805
|
+
Hz_SF: this.extractValue(buffer, 17, 'int16'),
|
|
667
806
|
WH_SF: this.extractValue(buffer, 26, 'int16'),
|
|
668
|
-
DCA_SF: this.extractValue(buffer,
|
|
669
|
-
DCV_SF: this.extractValue(buffer,
|
|
670
|
-
DCW_SF: this.extractValue(buffer,
|
|
807
|
+
DCA_SF: this.extractValue(buffer, 28, 'int16'),
|
|
808
|
+
DCV_SF: this.extractValue(buffer, 29, 'int16'),
|
|
809
|
+
DCW_SF: this.extractValue(buffer, 31, 'int16')
|
|
671
810
|
};
|
|
672
811
|
this.logRegisterRead(101, 6, 'A_SF', scaleFactors.A_SF, 'int16');
|
|
673
812
|
this.logRegisterRead(101, 13, 'V_SF', scaleFactors.V_SF, 'int16');
|
|
674
|
-
this.logRegisterRead(101,
|
|
675
|
-
this.logRegisterRead(101,
|
|
813
|
+
this.logRegisterRead(101, 15, 'W_SF', scaleFactors.W_SF, 'int16');
|
|
814
|
+
this.logRegisterRead(101, 17, 'Hz_SF', scaleFactors.Hz_SF, 'int16');
|
|
676
815
|
this.logRegisterRead(101, 26, 'WH_SF', scaleFactors.WH_SF, 'int16');
|
|
677
|
-
this.logRegisterRead(101,
|
|
678
|
-
this.logRegisterRead(101,
|
|
679
|
-
this.logRegisterRead(101,
|
|
680
|
-
// Extract raw values from buffer
|
|
816
|
+
this.logRegisterRead(101, 28, 'DCA_SF', scaleFactors.DCA_SF, 'int16');
|
|
817
|
+
this.logRegisterRead(101, 29, 'DCV_SF', scaleFactors.DCV_SF, 'int16');
|
|
818
|
+
this.logRegisterRead(101, 31, 'DCW_SF', scaleFactors.DCW_SF, 'int16');
|
|
681
819
|
const acCurrentRaw = this.extractValue(buffer, 2, 'uint16');
|
|
682
|
-
const voltageRaw = this.extractValue(buffer,
|
|
683
|
-
const acPowerRaw = this.extractValue(buffer,
|
|
684
|
-
const freqRaw = this.extractValue(buffer,
|
|
685
|
-
const dcCurrentRaw = this.extractValue(buffer,
|
|
686
|
-
const dcVoltageRaw = this.extractValue(buffer,
|
|
687
|
-
const dcPowerRaw = this.extractValue(buffer,
|
|
688
|
-
const stateRaw = this.extractValue(buffer,
|
|
689
|
-
this.logRegisterRead(101,
|
|
820
|
+
const voltageRaw = this.extractValue(buffer, 10, 'uint16');
|
|
821
|
+
const acPowerRaw = this.extractValue(buffer, 14, 'int16');
|
|
822
|
+
const freqRaw = this.extractValue(buffer, 16, 'uint16');
|
|
823
|
+
const dcCurrentRaw = this.extractValue(buffer, 27, 'uint16');
|
|
824
|
+
const dcVoltageRaw = this.extractValue(buffer, 28, 'uint16');
|
|
825
|
+
const dcPowerRaw = this.extractValue(buffer, 30, 'int16');
|
|
826
|
+
const stateRaw = this.extractValue(buffer, 38, 'uint16');
|
|
827
|
+
this.logRegisterRead(101, 38, 'Operating State', stateRaw, 'enum16');
|
|
690
828
|
const data = {
|
|
691
829
|
blockNumber: 101,
|
|
692
|
-
voltageAN: this.applyScaleFactor(voltageRaw, scaleFactors.V_SF, 'uint16', 'Voltage AN',
|
|
830
|
+
voltageAN: this.applyScaleFactor(voltageRaw, scaleFactors.V_SF, 'uint16', 'Voltage AN', 10, 101),
|
|
693
831
|
acCurrent: this.applyScaleFactor(acCurrentRaw, scaleFactors.A_SF, 'uint16', 'AC Current', 2, 101),
|
|
694
|
-
acPower: this.applyScaleFactor(acPowerRaw, scaleFactors.W_SF, 'int16', 'AC Power',
|
|
695
|
-
frequency: this.applyScaleFactor(freqRaw, scaleFactors.Hz_SF, 'uint16', 'Frequency',
|
|
696
|
-
dcCurrent: this.applyScaleFactor(dcCurrentRaw, scaleFactors.DCA_SF, 'uint16', 'DC Current',
|
|
697
|
-
dcVoltage: this.applyScaleFactor(dcVoltageRaw, scaleFactors.DCV_SF, 'uint16', 'DC Voltage',
|
|
698
|
-
dcPower: this.applyScaleFactor(dcPowerRaw, scaleFactors.DCW_SF, 'int16', 'DC Power',
|
|
832
|
+
acPower: this.applyScaleFactor(acPowerRaw, scaleFactors.W_SF, 'int16', 'AC Power', 14, 101),
|
|
833
|
+
frequency: this.applyScaleFactor(freqRaw, scaleFactors.Hz_SF, 'uint16', 'Frequency', 16, 101),
|
|
834
|
+
dcCurrent: this.applyScaleFactor(dcCurrentRaw, scaleFactors.DCA_SF, 'uint16', 'DC Current', 27, 101),
|
|
835
|
+
dcVoltage: this.applyScaleFactor(dcVoltageRaw, scaleFactors.DCV_SF, 'uint16', 'DC Voltage', 28, 101),
|
|
836
|
+
dcPower: this.applyScaleFactor(dcPowerRaw, scaleFactors.DCW_SF, 'int16', 'DC Power', 30, 101),
|
|
699
837
|
operatingState: stateRaw
|
|
700
838
|
};
|
|
701
839
|
// Read AC Energy (32-bit accumulator) - Offset 24-25
|
|
@@ -712,6 +850,70 @@ class SunspecModbusClient {
|
|
|
712
850
|
return null;
|
|
713
851
|
}
|
|
714
852
|
}
|
|
853
|
+
/**
|
|
854
|
+
* Read inverter data from a float-variant model: 111 (single-phase), 112 (split-phase), or 113 (three-phase).
|
|
855
|
+
*
|
|
856
|
+
* All three models share the same SunSpec register layout — measurement fields are 32-bit IEEE 754 floats
|
|
857
|
+
* (no scale factors), status registers stay enum16, event registers stay bitfield32. Phase scope differs
|
|
858
|
+
* by model: 111 populates phase A only; 112 phases A+B; 113 all three. Unpopulated phase fields read NaN
|
|
859
|
+
* and are returned as undefined.
|
|
860
|
+
*/
|
|
861
|
+
async readFloatInverterData(unitId, model, blockNumber) {
|
|
862
|
+
try {
|
|
863
|
+
console.debug(`Reading Float Inverter Data from Model ${blockNumber} at base address: ${model.address} (unit ${unitId})`);
|
|
864
|
+
const buffer = await this.readModelBlock(unitId, model);
|
|
865
|
+
const data = {
|
|
866
|
+
blockNumber,
|
|
867
|
+
blockAddress: model.address,
|
|
868
|
+
blockLength: model.length,
|
|
869
|
+
acCurrent: this.extractFloat32OrUndefined(buffer, 2, blockNumber, 'AC Current'),
|
|
870
|
+
phaseACurrent: this.extractFloat32OrUndefined(buffer, 4, blockNumber, 'Phase A Current'),
|
|
871
|
+
phaseBCurrent: this.extractFloat32OrUndefined(buffer, 6, blockNumber, 'Phase B Current'),
|
|
872
|
+
phaseCCurrent: this.extractFloat32OrUndefined(buffer, 8, blockNumber, 'Phase C Current'),
|
|
873
|
+
voltageAB: this.extractFloat32OrUndefined(buffer, 10, blockNumber, 'Voltage AB'),
|
|
874
|
+
voltageBC: this.extractFloat32OrUndefined(buffer, 12, blockNumber, 'Voltage BC'),
|
|
875
|
+
voltageCA: this.extractFloat32OrUndefined(buffer, 14, blockNumber, 'Voltage CA'),
|
|
876
|
+
voltageAN: this.extractFloat32OrUndefined(buffer, 16, blockNumber, 'Voltage AN'),
|
|
877
|
+
voltageBN: this.extractFloat32OrUndefined(buffer, 18, blockNumber, 'Voltage BN'),
|
|
878
|
+
voltageCN: this.extractFloat32OrUndefined(buffer, 20, blockNumber, 'Voltage CN'),
|
|
879
|
+
acPower: this.extractFloat32OrUndefined(buffer, 22, blockNumber, 'AC Power'),
|
|
880
|
+
frequency: this.extractFloat32OrUndefined(buffer, 24, blockNumber, 'Frequency'),
|
|
881
|
+
apparentPower: this.extractFloat32OrUndefined(buffer, 26, blockNumber, 'Apparent Power'),
|
|
882
|
+
reactivePower: this.extractFloat32OrUndefined(buffer, 28, blockNumber, 'Reactive Power'),
|
|
883
|
+
powerFactor: this.extractFloat32OrUndefined(buffer, 30, blockNumber, 'Power Factor'),
|
|
884
|
+
acEnergy: this.extractFloat32OrUndefined(buffer, 32, blockNumber, 'AC Energy'),
|
|
885
|
+
dcCurrent: this.extractFloat32OrUndefined(buffer, 34, blockNumber, 'DC Current'),
|
|
886
|
+
dcVoltage: this.extractFloat32OrUndefined(buffer, 36, blockNumber, 'DC Voltage'),
|
|
887
|
+
dcPower: this.extractFloat32OrUndefined(buffer, 38, blockNumber, 'DC Power'),
|
|
888
|
+
cabinetTemperature: this.extractFloat32OrUndefined(buffer, 40, blockNumber, 'Cabinet Temperature'),
|
|
889
|
+
heatSinkTemperature: this.extractFloat32OrUndefined(buffer, 42, blockNumber, 'Heat Sink Temperature'),
|
|
890
|
+
transformerTemperature: this.extractFloat32OrUndefined(buffer, 44, blockNumber, 'Transformer Temperature'),
|
|
891
|
+
otherTemperature: this.extractFloat32OrUndefined(buffer, 46, blockNumber, 'Other Temperature'),
|
|
892
|
+
operatingState: this.extractValue(buffer, 48, 'uint16'),
|
|
893
|
+
vendorState: this.extractValue(buffer, 49, 'uint16'),
|
|
894
|
+
events: this.extractValue(buffer, 50, 'uint32', 2),
|
|
895
|
+
events2: this.extractValue(buffer, 52, 'uint32', 2),
|
|
896
|
+
vendorEvents1: this.extractValue(buffer, 54, 'uint32', 2),
|
|
897
|
+
vendorEvents2: this.extractValue(buffer, 56, 'uint32', 2),
|
|
898
|
+
vendorEvents3: this.extractValue(buffer, 58, 'uint32', 2),
|
|
899
|
+
vendorEvents4: this.extractValue(buffer, 60, 'uint32', 2),
|
|
900
|
+
};
|
|
901
|
+
this.logRegisterRead(blockNumber, 48, 'Operating State', data.operatingState, 'enum16');
|
|
902
|
+
this.logRegisterRead(blockNumber, 49, 'Vendor State', data.vendorState, 'enum16');
|
|
903
|
+
this.logRegisterRead(blockNumber, 50, 'Events', data.events, 'bitfield32');
|
|
904
|
+
this.logRegisterRead(blockNumber, 52, 'Events2', data.events2, 'bitfield32');
|
|
905
|
+
this.logRegisterRead(blockNumber, 54, 'Vendor Events 1', data.vendorEvents1, 'bitfield32');
|
|
906
|
+
this.logRegisterRead(blockNumber, 56, 'Vendor Events 2', data.vendorEvents2, 'bitfield32');
|
|
907
|
+
this.logRegisterRead(blockNumber, 58, 'Vendor Events 3', data.vendorEvents3, 'bitfield32');
|
|
908
|
+
this.logRegisterRead(blockNumber, 60, 'Vendor Events 4', data.vendorEvents4, 'bitfield32');
|
|
909
|
+
console.debug(`[Model ${blockNumber}] Float Inverter Data:`, data);
|
|
910
|
+
return data;
|
|
911
|
+
}
|
|
912
|
+
catch (error) {
|
|
913
|
+
console.error(`Error reading float inverter data (Model ${blockNumber}): ${error}`);
|
|
914
|
+
return null;
|
|
915
|
+
}
|
|
916
|
+
}
|
|
715
917
|
/**
|
|
716
918
|
* Extract inverter scale factors from a pre-read model buffer
|
|
717
919
|
*/
|
|
@@ -779,6 +981,16 @@ class SunspecModbusClient {
|
|
|
779
981
|
console.debug(`[Model ${modelId}] offset ${offset}: ${fieldName} = "${rawValue}"${typeInfo}`);
|
|
780
982
|
}
|
|
781
983
|
}
|
|
984
|
+
/**
|
|
985
|
+
* Read a float32 (IEEE 754, big-endian, 2 registers) from a model buffer.
|
|
986
|
+
* SunSpec uses NaN as the "unimplemented" sentinel for floats; returns undefined in that case.
|
|
987
|
+
*/
|
|
988
|
+
extractFloat32OrUndefined(buffer, offset, modelId, fieldName) {
|
|
989
|
+
const value = this.extractValue(buffer, offset, 'float32', 2);
|
|
990
|
+
const display = Number.isNaN(value) ? 'NaN' : value;
|
|
991
|
+
console.debug(`[Model ${modelId}] offset ${offset}: ${fieldName} = ${display} (float32)`);
|
|
992
|
+
return Number.isNaN(value) ? undefined : value;
|
|
993
|
+
}
|
|
782
994
|
/**
|
|
783
995
|
* Read scale factors from Model 160 (MPPT)
|
|
784
996
|
*
|
|
@@ -806,18 +1018,18 @@ class SunspecModbusClient {
|
|
|
806
1018
|
this.logRegisterRead(160, 5, 'DCWH_SF', scaleFactors.DCWH_SF, 'int16');
|
|
807
1019
|
return scaleFactors;
|
|
808
1020
|
}
|
|
809
|
-
async readMPPTScaleFactors() {
|
|
810
|
-
const model = this.findModel(sunspec_interfaces_js_1.SunspecModelId.MPPT);
|
|
1021
|
+
async readMPPTScaleFactors(unitId) {
|
|
1022
|
+
const model = this.findModel(unitId, sunspec_interfaces_js_1.SunspecModelId.MPPT);
|
|
811
1023
|
if (!model) {
|
|
812
|
-
console.debug(
|
|
1024
|
+
console.debug(`MPPT model 160 not found on unit ${unitId}`);
|
|
813
1025
|
return null;
|
|
814
1026
|
}
|
|
815
1027
|
try {
|
|
816
|
-
const buffer = await this.readModelBlock(model);
|
|
1028
|
+
const buffer = await this.readModelBlock(unitId, model);
|
|
817
1029
|
return this.extractMPPTScaleFactors(buffer);
|
|
818
1030
|
}
|
|
819
1031
|
catch (error) {
|
|
820
|
-
console.error(`Error reading MPPT scale factors: ${error}`);
|
|
1032
|
+
console.error(`Error reading MPPT scale factors (unit ${unitId}): ${error}`);
|
|
821
1033
|
return null;
|
|
822
1034
|
}
|
|
823
1035
|
}
|
|
@@ -878,36 +1090,36 @@ class SunspecModbusClient {
|
|
|
878
1090
|
/**
|
|
879
1091
|
* Read MPPT data from Model 160
|
|
880
1092
|
*/
|
|
881
|
-
async readMPPTData(moduleId = 1) {
|
|
882
|
-
const model = this.findModel(sunspec_interfaces_js_1.SunspecModelId.MPPT);
|
|
1093
|
+
async readMPPTData(unitId, moduleId = 1) {
|
|
1094
|
+
const model = this.findModel(unitId, sunspec_interfaces_js_1.SunspecModelId.MPPT);
|
|
883
1095
|
if (!model) {
|
|
884
|
-
console.debug(
|
|
1096
|
+
console.debug(`MPPT model 160 not found on unit ${unitId}`);
|
|
885
1097
|
return null;
|
|
886
1098
|
}
|
|
887
1099
|
try {
|
|
888
1100
|
// Read entire model block in a single Modbus call
|
|
889
|
-
const buffer = await this.readModelBlock(model);
|
|
1101
|
+
const buffer = await this.readModelBlock(unitId, model);
|
|
890
1102
|
const scaleFactors = this.extractMPPTScaleFactors(buffer);
|
|
891
1103
|
return this.extractMPPTModuleData(buffer, model, moduleId, scaleFactors);
|
|
892
1104
|
}
|
|
893
1105
|
catch (error) {
|
|
894
|
-
console.error(`Error reading MPPT data for module ${moduleId}: ${error}`);
|
|
1106
|
+
console.error(`Error reading MPPT data for module ${moduleId} (unit ${unitId}): ${error}`);
|
|
895
1107
|
return null;
|
|
896
1108
|
}
|
|
897
1109
|
}
|
|
898
1110
|
/**
|
|
899
1111
|
* Read all MPPT strings from Model 160 (Multiple MPPT)
|
|
900
1112
|
*/
|
|
901
|
-
async readAllMPPTData() {
|
|
1113
|
+
async readAllMPPTData(unitId) {
|
|
902
1114
|
const mpptData = [];
|
|
903
|
-
const model = this.findModel(sunspec_interfaces_js_1.SunspecModelId.MPPT);
|
|
1115
|
+
const model = this.findModel(unitId, sunspec_interfaces_js_1.SunspecModelId.MPPT);
|
|
904
1116
|
if (!model) {
|
|
905
|
-
console.debug(
|
|
1117
|
+
console.debug(`MPPT model 160 not found on unit ${unitId}`);
|
|
906
1118
|
return [];
|
|
907
1119
|
}
|
|
908
1120
|
try {
|
|
909
1121
|
// Read entire model block in a single Modbus call
|
|
910
|
-
const buffer = await this.readModelBlock(model);
|
|
1122
|
+
const buffer = await this.readModelBlock(unitId, model);
|
|
911
1123
|
const scaleFactors = this.extractMPPTScaleFactors(buffer);
|
|
912
1124
|
// Read the module count from register 8
|
|
913
1125
|
let moduleCount = 4; // Default fallback value
|
|
@@ -1068,16 +1280,16 @@ class SunspecModbusClient {
|
|
|
1068
1280
|
/**
|
|
1069
1281
|
* Read battery base data from Model 802 (Battery Base)
|
|
1070
1282
|
*/
|
|
1071
|
-
async readBatteryBaseData() {
|
|
1072
|
-
const model = this.findModel(sunspec_interfaces_js_1.SunspecModelId.BatteryBase);
|
|
1283
|
+
async readBatteryBaseData(unitId) {
|
|
1284
|
+
const model = this.findModel(unitId, sunspec_interfaces_js_1.SunspecModelId.BatteryBase);
|
|
1073
1285
|
if (!model) {
|
|
1074
|
-
console.debug(
|
|
1286
|
+
console.debug(`Battery Base model 802 not found on unit ${unitId}`);
|
|
1075
1287
|
return null;
|
|
1076
1288
|
}
|
|
1077
|
-
console.debug(`Reading Battery Base Data from Model 802 at base address: ${model.address}`);
|
|
1289
|
+
console.debug(`Reading Battery Base Data from Model 802 at base address: ${model.address} (unit ${unitId})`);
|
|
1078
1290
|
try {
|
|
1079
1291
|
// Read entire model block in a single Modbus call
|
|
1080
|
-
const buffer = await this.readModelBlock(model);
|
|
1292
|
+
const buffer = await this.readModelBlock(unitId, model);
|
|
1081
1293
|
// Extract scale factors from buffer (offsets 52-63)
|
|
1082
1294
|
const sf = this.extractBatteryBaseScaleFactors(buffer);
|
|
1083
1295
|
// Extract raw values from buffer
|
|
@@ -1206,27 +1418,27 @@ class SunspecModbusClient {
|
|
|
1206
1418
|
/**
|
|
1207
1419
|
* Read battery data from Model 124 (Basic Storage) with fallback to Model 802 / Model 803
|
|
1208
1420
|
*/
|
|
1209
|
-
async readBatteryData() {
|
|
1421
|
+
async readBatteryData(unitId) {
|
|
1210
1422
|
// Try Model 124 first (Basic Storage Controls)
|
|
1211
|
-
let model = this.findModel(sunspec_interfaces_js_1.SunspecModelId.Battery);
|
|
1423
|
+
let model = this.findModel(unitId, sunspec_interfaces_js_1.SunspecModelId.Battery);
|
|
1212
1424
|
// Fall back to other battery models if needed
|
|
1213
1425
|
if (!model) {
|
|
1214
|
-
model = this.findModel(sunspec_interfaces_js_1.SunspecModelId.BatteryBase);
|
|
1426
|
+
model = this.findModel(unitId, sunspec_interfaces_js_1.SunspecModelId.BatteryBase);
|
|
1215
1427
|
}
|
|
1216
1428
|
if (!model) {
|
|
1217
|
-
model = this.findModel(sunspec_interfaces_js_1.SunspecModelId.BatteryControl);
|
|
1429
|
+
model = this.findModel(unitId, sunspec_interfaces_js_1.SunspecModelId.BatteryControl);
|
|
1218
1430
|
}
|
|
1219
1431
|
if (!model) {
|
|
1220
|
-
console.debug(
|
|
1432
|
+
console.debug(`No battery model found on unit ${unitId}`);
|
|
1221
1433
|
return null;
|
|
1222
1434
|
}
|
|
1223
|
-
console.debug(`Reading Battery Data from Model ${model.id} at base address: ${model.address}`);
|
|
1435
|
+
console.debug(`Reading Battery Data from Model ${model.id} at base address: ${model.address} (unit ${unitId})`);
|
|
1224
1436
|
try {
|
|
1225
1437
|
if (model.id === 124) {
|
|
1226
1438
|
// Model 124: Basic Storage Controls
|
|
1227
1439
|
console.debug('Using Model 124 (Basic Storage Controls)');
|
|
1228
1440
|
// Read entire model block in a single Modbus call
|
|
1229
|
-
const buffer = await this.readModelBlock(model);
|
|
1441
|
+
const buffer = await this.readModelBlock(unitId, model);
|
|
1230
1442
|
// Extract scale factors from buffer (offsets 18-25)
|
|
1231
1443
|
const scaleFactors = {
|
|
1232
1444
|
WChaMax_SF: this.extractValue(buffer, 18, 'int16'),
|
|
@@ -1327,7 +1539,7 @@ class SunspecModbusClient {
|
|
|
1327
1539
|
else if (model.id === 802) {
|
|
1328
1540
|
// Model 802: Battery Base
|
|
1329
1541
|
console.debug('Using Model 802 (Battery Base)');
|
|
1330
|
-
const baseData = await this.readBatteryBaseData();
|
|
1542
|
+
const baseData = await this.readBatteryBaseData(unitId);
|
|
1331
1543
|
if (!baseData) {
|
|
1332
1544
|
return null;
|
|
1333
1545
|
}
|
|
@@ -1379,55 +1591,55 @@ class SunspecModbusClient {
|
|
|
1379
1591
|
/**
|
|
1380
1592
|
* Write battery control settings to Model 124
|
|
1381
1593
|
*/
|
|
1382
|
-
async writeBatteryControls(controls) {
|
|
1383
|
-
const model = this.findModel(sunspec_interfaces_js_1.SunspecModelId.Battery);
|
|
1594
|
+
async writeBatteryControls(unitId, controls) {
|
|
1595
|
+
const model = this.findModel(unitId, sunspec_interfaces_js_1.SunspecModelId.Battery);
|
|
1384
1596
|
if (!model) {
|
|
1385
|
-
console.error(
|
|
1597
|
+
console.error(`Battery model 124 not found on unit ${unitId}`);
|
|
1386
1598
|
return false;
|
|
1387
1599
|
}
|
|
1388
1600
|
const baseAddr = model.address;
|
|
1389
|
-
console.log(`Writing Battery Controls to Model 124 at base address: ${baseAddr}`);
|
|
1601
|
+
console.log(`Writing Battery Controls to Model 124 at base address: ${baseAddr} (unit ${unitId})`);
|
|
1390
1602
|
try {
|
|
1391
1603
|
// Write storage control mode (Register 5)
|
|
1392
1604
|
if (controls.storCtlMod !== undefined) {
|
|
1393
|
-
await this.writeRegisterValue(baseAddr + 5, controls.storCtlMod, 'uint16');
|
|
1605
|
+
await this.writeRegisterValue(unitId, baseAddr + 5, controls.storCtlMod, 'uint16');
|
|
1394
1606
|
console.log(`Set storage control mode to 0x${controls.storCtlMod.toString(16)}`);
|
|
1395
1607
|
}
|
|
1396
1608
|
// Write charge source setting (Register 17)
|
|
1397
1609
|
if (controls.chaGriSet !== undefined) {
|
|
1398
|
-
await this.writeRegisterValue(baseAddr + 17, controls.chaGriSet, 'uint16');
|
|
1610
|
+
await this.writeRegisterValue(unitId, baseAddr + 17, controls.chaGriSet, 'uint16');
|
|
1399
1611
|
console.log(`Set charge source to ${controls.chaGriSet === sunspec_interfaces_js_1.SunspecChargeSource.GRID ? 'GRID' : 'PV'}`);
|
|
1400
1612
|
}
|
|
1401
1613
|
// Write maximum charge power (Register 2) - needs scale factor
|
|
1402
1614
|
if (controls.wChaMax !== undefined) {
|
|
1403
1615
|
const scaleFactorAddr = baseAddr + 18;
|
|
1404
|
-
const scaleFactor = await this.readRegisterValue(scaleFactorAddr, 1, 'int16');
|
|
1616
|
+
const scaleFactor = await this.readRegisterValue(unitId, scaleFactorAddr, 1, 'int16');
|
|
1405
1617
|
const scaledValue = Math.round(controls.wChaMax / Math.pow(10, scaleFactor));
|
|
1406
|
-
await this.writeRegisterValue(baseAddr + 2, scaledValue, 'uint16');
|
|
1618
|
+
await this.writeRegisterValue(unitId, baseAddr + 2, scaledValue, 'uint16');
|
|
1407
1619
|
console.log(`Set max charge power to ${controls.wChaMax}W (scaled: ${scaledValue})`);
|
|
1408
1620
|
}
|
|
1409
1621
|
// Write charge rate (Register 13) - needs scale factor
|
|
1410
1622
|
if (controls.inWRte !== undefined) {
|
|
1411
1623
|
const scaleFactorAddr = baseAddr + 25;
|
|
1412
|
-
const scaleFactor = await this.readRegisterValue(scaleFactorAddr, 1, 'int16');
|
|
1624
|
+
const scaleFactor = await this.readRegisterValue(unitId, scaleFactorAddr, 1, 'int16');
|
|
1413
1625
|
const scaledValue = Math.round(controls.inWRte / Math.pow(10, scaleFactor));
|
|
1414
|
-
await this.writeRegisterValue(baseAddr + 13, scaledValue, 'int16');
|
|
1626
|
+
await this.writeRegisterValue(unitId, baseAddr + 13, scaledValue, 'int16');
|
|
1415
1627
|
console.log(`Set charge rate to ${controls.inWRte}% (scaled: ${scaledValue})`);
|
|
1416
1628
|
}
|
|
1417
1629
|
// Write discharge rate (Register 12) - needs scale factor
|
|
1418
1630
|
if (controls.outWRte !== undefined) {
|
|
1419
1631
|
const scaleFactorAddr = baseAddr + 25;
|
|
1420
|
-
const scaleFactor = await this.readRegisterValue(scaleFactorAddr, 1, 'int16');
|
|
1632
|
+
const scaleFactor = await this.readRegisterValue(unitId, scaleFactorAddr, 1, 'int16');
|
|
1421
1633
|
const scaledValue = Math.round(controls.outWRte / Math.pow(10, scaleFactor));
|
|
1422
|
-
await this.writeRegisterValue(baseAddr + 12, scaledValue, 'int16');
|
|
1634
|
+
await this.writeRegisterValue(unitId, baseAddr + 12, scaledValue, 'int16');
|
|
1423
1635
|
console.log(`Set discharge rate to ${controls.outWRte}% (scaled: ${scaledValue})`);
|
|
1424
1636
|
}
|
|
1425
1637
|
// Write minimum reserve percentage (Register 7) - needs scale factor
|
|
1426
1638
|
if (controls.minRsvPct !== undefined) {
|
|
1427
1639
|
const scaleFactorAddr = baseAddr + 21;
|
|
1428
|
-
const scaleFactor = await this.readRegisterValue(scaleFactorAddr, 1, 'int16');
|
|
1640
|
+
const scaleFactor = await this.readRegisterValue(unitId, scaleFactorAddr, 1, 'int16');
|
|
1429
1641
|
const scaledValue = Math.round(controls.minRsvPct / Math.pow(10, scaleFactor));
|
|
1430
|
-
await this.writeRegisterValue(baseAddr + 7, scaledValue, 'uint16');
|
|
1642
|
+
await this.writeRegisterValue(unitId, baseAddr + 7, scaledValue, 'uint16');
|
|
1431
1643
|
console.log(`Set minimum reserve to ${controls.minRsvPct}% (scaled: ${scaledValue})`);
|
|
1432
1644
|
}
|
|
1433
1645
|
console.log('Battery controls written successfully');
|
|
@@ -1441,7 +1653,7 @@ class SunspecModbusClient {
|
|
|
1441
1653
|
/**
|
|
1442
1654
|
* Set battery storage mode (simplified interface)
|
|
1443
1655
|
*/
|
|
1444
|
-
async setStorageMode(mode) {
|
|
1656
|
+
async setStorageMode(unitId, mode) {
|
|
1445
1657
|
let storCtlMod;
|
|
1446
1658
|
switch (mode) {
|
|
1447
1659
|
case sunspec_interfaces_js_1.SunspecStorageMode.CHARGE:
|
|
@@ -1465,29 +1677,29 @@ class SunspecModbusClient {
|
|
|
1465
1677
|
return false;
|
|
1466
1678
|
}
|
|
1467
1679
|
console.log(`Setting storage mode to ${mode} (control bits: 0x${storCtlMod.toString(16)})`);
|
|
1468
|
-
return this.writeBatteryControls({ storCtlMod });
|
|
1680
|
+
return this.writeBatteryControls(unitId, { storCtlMod });
|
|
1469
1681
|
}
|
|
1470
1682
|
/**
|
|
1471
1683
|
* Enable or disable grid charging
|
|
1472
1684
|
*/
|
|
1473
|
-
async enableGridCharging(enable) {
|
|
1685
|
+
async enableGridCharging(unitId, enable) {
|
|
1474
1686
|
const chaGriSet = enable ? sunspec_interfaces_js_1.SunspecChargeSource.GRID : sunspec_interfaces_js_1.SunspecChargeSource.PV;
|
|
1475
|
-
console.log(`${enable ? 'Enabling' : 'Disabling'} grid charging`);
|
|
1476
|
-
return this.writeBatteryControls({ chaGriSet });
|
|
1687
|
+
console.log(`${enable ? 'Enabling' : 'Disabling'} grid charging on unit ${unitId}`);
|
|
1688
|
+
return this.writeBatteryControls(unitId, { chaGriSet });
|
|
1477
1689
|
}
|
|
1478
1690
|
/**
|
|
1479
1691
|
* Read battery control settings from Model 124 (Basic Storage Controls)
|
|
1480
1692
|
*/
|
|
1481
|
-
async readBatteryControls() {
|
|
1482
|
-
const model = this.findModel(sunspec_interfaces_js_1.SunspecModelId.Battery);
|
|
1693
|
+
async readBatteryControls(unitId) {
|
|
1694
|
+
const model = this.findModel(unitId, sunspec_interfaces_js_1.SunspecModelId.Battery);
|
|
1483
1695
|
if (!model) {
|
|
1484
|
-
console.log(
|
|
1696
|
+
console.log(`Battery model 124 not found on unit ${unitId}`);
|
|
1485
1697
|
return null;
|
|
1486
1698
|
}
|
|
1487
|
-
console.log(`Reading Battery Controls from Model 124 at base address: ${model.address}`);
|
|
1699
|
+
console.log(`Reading Battery Controls from Model 124 at base address: ${model.address} (unit ${unitId})`);
|
|
1488
1700
|
try {
|
|
1489
1701
|
// Read entire model block in a single Modbus call
|
|
1490
|
-
const buffer = await this.readModelBlock(model);
|
|
1702
|
+
const buffer = await this.readModelBlock(unitId, model);
|
|
1491
1703
|
// Extract scale factors from buffer
|
|
1492
1704
|
const scaleFactors = {
|
|
1493
1705
|
WChaMax_SF: this.extractValue(buffer, 18, 'int16'),
|
|
@@ -1526,19 +1738,19 @@ class SunspecModbusClient {
|
|
|
1526
1738
|
/**
|
|
1527
1739
|
* Read meter data from Model 201 (Single Phase) / Model 203 (Three Phase) / Model 204 (Split Phase)
|
|
1528
1740
|
*/
|
|
1529
|
-
async readMeterData() {
|
|
1530
|
-
let model = this.findModel(sunspec_interfaces_js_1.SunspecModelId.Meter3Phase);
|
|
1741
|
+
async readMeterData(unitId) {
|
|
1742
|
+
let model = this.findModel(unitId, sunspec_interfaces_js_1.SunspecModelId.Meter3Phase);
|
|
1531
1743
|
if (!model) {
|
|
1532
|
-
model = this.findModel(sunspec_interfaces_js_1.SunspecModelId.MeterWye);
|
|
1744
|
+
model = this.findModel(unitId, sunspec_interfaces_js_1.SunspecModelId.MeterWye);
|
|
1533
1745
|
}
|
|
1534
1746
|
if (!model) {
|
|
1535
|
-
model = this.findModel(sunspec_interfaces_js_1.SunspecModelId.MeterSinglePhase);
|
|
1747
|
+
model = this.findModel(unitId, sunspec_interfaces_js_1.SunspecModelId.MeterSinglePhase);
|
|
1536
1748
|
}
|
|
1537
1749
|
if (!model) {
|
|
1538
|
-
console.debug(
|
|
1750
|
+
console.debug(`No meter model found on unit ${unitId}`);
|
|
1539
1751
|
return null;
|
|
1540
1752
|
}
|
|
1541
|
-
console.debug(`Reading Meter Data from Model ${model.id} at base address: ${model.address}`);
|
|
1753
|
+
console.debug(`Reading Meter Data from Model ${model.id} at base address: ${model.address} (unit ${unitId})`);
|
|
1542
1754
|
try {
|
|
1543
1755
|
// Different meter models have different register offsets
|
|
1544
1756
|
console.debug(`Meter is Model ${model.id}`);
|
|
@@ -1582,7 +1794,7 @@ class SunspecModbusClient {
|
|
|
1582
1794
|
energySFOffset = 54; // TotWh_SF - Total Energy scale factor
|
|
1583
1795
|
}
|
|
1584
1796
|
// Read entire model block in a single Modbus call
|
|
1585
|
-
const buffer = await this.readModelBlock(model);
|
|
1797
|
+
const buffer = await this.readModelBlock(unitId, model);
|
|
1586
1798
|
// Extract scale factors from buffer
|
|
1587
1799
|
const powerSF = this.extractValue(buffer, powerSFOffset, 'int16');
|
|
1588
1800
|
const freqSF = this.extractValue(buffer, freqSFOffset, 'int16');
|
|
@@ -1620,16 +1832,16 @@ class SunspecModbusClient {
|
|
|
1620
1832
|
/**
|
|
1621
1833
|
* Read common block data (Model 1)
|
|
1622
1834
|
*/
|
|
1623
|
-
async readCommonBlock() {
|
|
1624
|
-
const model = this.findModel(sunspec_interfaces_js_1.SunspecModelId.Common);
|
|
1835
|
+
async readCommonBlock(unitId) {
|
|
1836
|
+
const model = this.findModel(unitId, sunspec_interfaces_js_1.SunspecModelId.Common);
|
|
1625
1837
|
if (!model) {
|
|
1626
|
-
console.error(
|
|
1838
|
+
console.error(`Common block model not found on unit ${unitId}`);
|
|
1627
1839
|
return null;
|
|
1628
1840
|
}
|
|
1629
|
-
console.log(`Reading Common Block - Model address: ${model.address}`);
|
|
1841
|
+
console.log(`Reading Common Block - Model address: ${model.address} (unit ${unitId})`);
|
|
1630
1842
|
try {
|
|
1631
1843
|
// Read entire model block in a single Modbus call
|
|
1632
|
-
const buffer = await this.readModelBlock(model);
|
|
1844
|
+
const buffer = await this.readModelBlock(unitId, model);
|
|
1633
1845
|
// Common block offsets are relative to the model start (after ID and Length header,
|
|
1634
1846
|
// but readModelBlock reads from model.address which includes the data area).
|
|
1635
1847
|
// The offsets below are relative to the data start within the model block.
|
|
@@ -1666,24 +1878,24 @@ class SunspecModbusClient {
|
|
|
1666
1878
|
/**
|
|
1667
1879
|
* Get serial number from device
|
|
1668
1880
|
*/
|
|
1669
|
-
async getSerialNumber() {
|
|
1670
|
-
const commonData = await this.readCommonBlock();
|
|
1881
|
+
async getSerialNumber(unitId) {
|
|
1882
|
+
const commonData = await this.readCommonBlock(unitId);
|
|
1671
1883
|
return commonData?.serialNumber;
|
|
1672
1884
|
}
|
|
1673
1885
|
/**
|
|
1674
|
-
* Check if connected
|
|
1886
|
+
* Check if a specific unit is connected on this network device
|
|
1675
1887
|
*/
|
|
1676
|
-
isConnected() {
|
|
1677
|
-
return this.
|
|
1888
|
+
isConnected(unitId) {
|
|
1889
|
+
return this.modbusInstances.has(unitId);
|
|
1678
1890
|
}
|
|
1679
1891
|
/**
|
|
1680
|
-
* Check if connection is healthy
|
|
1892
|
+
* Check if a specific unit's connection is healthy
|
|
1681
1893
|
*/
|
|
1682
|
-
isHealthy() {
|
|
1683
|
-
return this.
|
|
1894
|
+
isHealthy(unitId) {
|
|
1895
|
+
return this.modbusInstances.has(unitId) && this.connectionHealth.isHealthy();
|
|
1684
1896
|
}
|
|
1685
1897
|
/**
|
|
1686
|
-
* Get connection health details
|
|
1898
|
+
* Get connection health details (shared across all units on this network device)
|
|
1687
1899
|
*/
|
|
1688
1900
|
getConnectionHealth() {
|
|
1689
1901
|
return this.connectionHealth;
|
|
@@ -1691,15 +1903,15 @@ class SunspecModbusClient {
|
|
|
1691
1903
|
/**
|
|
1692
1904
|
* Read inverter settings from Model 121 (Inverter Settings)
|
|
1693
1905
|
*/
|
|
1694
|
-
async readInverterSettings() {
|
|
1695
|
-
const model = this.findModel(sunspec_interfaces_js_1.SunspecModelId.Settings);
|
|
1906
|
+
async readInverterSettings(unitId) {
|
|
1907
|
+
const model = this.findModel(unitId, sunspec_interfaces_js_1.SunspecModelId.Settings);
|
|
1696
1908
|
if (!model) {
|
|
1697
|
-
console.debug(
|
|
1909
|
+
console.debug(`Settings model 121 not found on unit ${unitId}`);
|
|
1698
1910
|
return null;
|
|
1699
1911
|
}
|
|
1700
1912
|
try {
|
|
1701
1913
|
// Read entire model block in a single Modbus call
|
|
1702
|
-
const buffer = await this.readModelBlock(model);
|
|
1914
|
+
const buffer = await this.readModelBlock(unitId, model);
|
|
1703
1915
|
// Extract scale factors from buffer (offsets 22-31)
|
|
1704
1916
|
const scaleFactors = {
|
|
1705
1917
|
WMax_SF: this.extractValue(buffer, 22, 'int16'),
|
|
@@ -1783,15 +1995,15 @@ class SunspecModbusClient {
|
|
|
1783
1995
|
/**
|
|
1784
1996
|
* Read inverter controls from Model 123 (Immediate Inverter Controls)
|
|
1785
1997
|
*/
|
|
1786
|
-
async readInverterControls() {
|
|
1787
|
-
const model = this.findModel(sunspec_interfaces_js_1.SunspecModelId.Controls);
|
|
1998
|
+
async readInverterControls(unitId) {
|
|
1999
|
+
const model = this.findModel(unitId, sunspec_interfaces_js_1.SunspecModelId.Controls);
|
|
1788
2000
|
if (!model) {
|
|
1789
|
-
console.log(
|
|
2001
|
+
console.log(`Controls model 123 not found on unit ${unitId}`);
|
|
1790
2002
|
return null;
|
|
1791
2003
|
}
|
|
1792
2004
|
try {
|
|
1793
2005
|
// Read entire model block in a single Modbus call
|
|
1794
|
-
const buffer = await this.readModelBlock(model);
|
|
2006
|
+
const buffer = await this.readModelBlock(unitId, model);
|
|
1795
2007
|
// Extract scale factors from buffer (offsets 21-23)
|
|
1796
2008
|
const scaleFactors = {
|
|
1797
2009
|
WMaxLimPct_SF: this.extractValue(buffer, 21, 'int16'),
|
|
@@ -1878,27 +2090,28 @@ class SunspecModbusClient {
|
|
|
1878
2090
|
/**
|
|
1879
2091
|
* Write Block 121 - Inverter Basic Settings
|
|
1880
2092
|
*/
|
|
1881
|
-
async writeInverterSettings(settings) {
|
|
1882
|
-
const model = this.findModel(sunspec_interfaces_js_1.SunspecModelId.Settings);
|
|
2093
|
+
async writeInverterSettings(unitId, settings) {
|
|
2094
|
+
const model = this.findModel(unitId, sunspec_interfaces_js_1.SunspecModelId.Settings);
|
|
1883
2095
|
if (!model) {
|
|
1884
|
-
console.error(
|
|
2096
|
+
console.error(`Settings model 121 not found on unit ${unitId}`);
|
|
1885
2097
|
return false;
|
|
1886
2098
|
}
|
|
1887
2099
|
const baseAddr = model.address;
|
|
1888
2100
|
try {
|
|
2101
|
+
const instance = this.getInstance(unitId);
|
|
1889
2102
|
// For each setting, write the value if provided
|
|
1890
2103
|
// Note: This is a simplified implementation. In production, you'd batch writes
|
|
1891
|
-
if (settings.WMax !== undefined
|
|
2104
|
+
if (settings.WMax !== undefined) {
|
|
1892
2105
|
// Need to read scale factor first if not provided
|
|
1893
|
-
const sfBuffer = await
|
|
2106
|
+
const sfBuffer = await instance.readHoldingRegisters(baseAddr + 22, 1);
|
|
1894
2107
|
const scaleFactor = sfBuffer.readInt16BE(0);
|
|
1895
2108
|
const scaledValue = Math.round(settings.WMax / Math.pow(10, scaleFactor));
|
|
1896
2109
|
// Writing registers needs to be implemented in EnergyAppModbusInstance
|
|
1897
2110
|
// For now, log the write operation
|
|
1898
2111
|
console.log(`Would write value ${scaledValue} to register ${baseAddr}`);
|
|
1899
2112
|
}
|
|
1900
|
-
if (settings.VRef !== undefined
|
|
1901
|
-
const sfBuffer = await
|
|
2113
|
+
if (settings.VRef !== undefined) {
|
|
2114
|
+
const sfBuffer = await instance.readHoldingRegisters(baseAddr + 23, 1);
|
|
1902
2115
|
const scaleFactor = sfBuffer.readInt16BE(0);
|
|
1903
2116
|
const scaledValue = Math.round(settings.VRef / Math.pow(10, scaleFactor));
|
|
1904
2117
|
console.log(`Would write value ${scaledValue} to register ${baseAddr + 1}`);
|
|
@@ -1915,41 +2128,41 @@ class SunspecModbusClient {
|
|
|
1915
2128
|
/**
|
|
1916
2129
|
* Write inverter controls to Model 123 (Immediate Inverter Controls)
|
|
1917
2130
|
*/
|
|
1918
|
-
async writeInverterControls(controls) {
|
|
1919
|
-
const model = this.findModel(sunspec_interfaces_js_1.SunspecModelId.Controls);
|
|
2131
|
+
async writeInverterControls(unitId, controls) {
|
|
2132
|
+
const model = this.findModel(unitId, sunspec_interfaces_js_1.SunspecModelId.Controls);
|
|
1920
2133
|
if (!model) {
|
|
1921
|
-
console.error(
|
|
2134
|
+
console.error(`Controls model 123 not found on unit ${unitId}`);
|
|
1922
2135
|
return false;
|
|
1923
2136
|
}
|
|
1924
2137
|
const baseAddr = model.address;
|
|
1925
2138
|
try {
|
|
1926
2139
|
// Connection control (Register 2)
|
|
1927
2140
|
if (controls.Conn !== undefined) {
|
|
1928
|
-
await this.writeRegisterValue(baseAddr + 2, controls.Conn, 'uint16');
|
|
2141
|
+
await this.writeRegisterValue(unitId, baseAddr + 2, controls.Conn, 'uint16');
|
|
1929
2142
|
console.log(`Set connection control to ${controls.Conn}`);
|
|
1930
2143
|
}
|
|
1931
2144
|
// Power limit control (Register 3) - needs scale factor
|
|
1932
2145
|
if (controls.WMaxLimPct !== undefined) {
|
|
1933
|
-
const scaleFactor = await this.readRegisterValue(baseAddr + 21, 1, 'int16');
|
|
2146
|
+
const scaleFactor = await this.readRegisterValue(unitId, baseAddr + 21, 1, 'int16');
|
|
1934
2147
|
const scaledValue = Math.round(controls.WMaxLimPct / Math.pow(10, scaleFactor));
|
|
1935
|
-
await this.writeRegisterValue(baseAddr + 3, scaledValue, 'uint16');
|
|
2148
|
+
await this.writeRegisterValue(unitId, baseAddr + 3, scaledValue, 'uint16');
|
|
1936
2149
|
console.log(`Set power limit to ${controls.WMaxLimPct}% (scaled: ${scaledValue})`);
|
|
1937
2150
|
}
|
|
1938
2151
|
// Throttle enable/disable (Register 7)
|
|
1939
2152
|
if (controls.WMaxLim_Ena !== undefined) {
|
|
1940
|
-
await this.writeRegisterValue(baseAddr + 7, controls.WMaxLim_Ena, 'uint16');
|
|
2153
|
+
await this.writeRegisterValue(unitId, baseAddr + 7, controls.WMaxLim_Ena, 'uint16');
|
|
1941
2154
|
console.log(`Set throttle enable to ${controls.WMaxLim_Ena}`);
|
|
1942
2155
|
}
|
|
1943
2156
|
// Power factor control (Register 8) - needs scale factor
|
|
1944
2157
|
if (controls.OutPFSet !== undefined) {
|
|
1945
|
-
const scaleFactor = await this.readRegisterValue(baseAddr + 22, 1, 'int16');
|
|
2158
|
+
const scaleFactor = await this.readRegisterValue(unitId, baseAddr + 22, 1, 'int16');
|
|
1946
2159
|
const scaledValue = Math.round(controls.OutPFSet / Math.pow(10, scaleFactor));
|
|
1947
|
-
await this.writeRegisterValue(baseAddr + 8, scaledValue, 'int16');
|
|
2160
|
+
await this.writeRegisterValue(unitId, baseAddr + 8, scaledValue, 'int16');
|
|
1948
2161
|
console.log(`Set power factor to ${controls.OutPFSet} (scaled: ${scaledValue})`);
|
|
1949
2162
|
}
|
|
1950
2163
|
// Power factor enable/disable (Register 12)
|
|
1951
2164
|
if (controls.OutPFSet_Ena !== undefined) {
|
|
1952
|
-
await this.writeRegisterValue(baseAddr + 12, controls.OutPFSet_Ena, 'uint16');
|
|
2165
|
+
await this.writeRegisterValue(unitId, baseAddr + 12, controls.OutPFSet_Ena, 'uint16');
|
|
1953
2166
|
console.log(`Set PF enable to ${controls.OutPFSet_Ena}`);
|
|
1954
2167
|
}
|
|
1955
2168
|
console.log('Inverter controls written successfully');
|
|
@@ -1970,21 +2183,21 @@ class SunspecModbusClient {
|
|
|
1970
2183
|
* @param limitW - Power limit in Watts, or null to remove the limit
|
|
1971
2184
|
* @returns true if successful, false otherwise
|
|
1972
2185
|
*/
|
|
1973
|
-
async setFeedInLimit(limitW) {
|
|
2186
|
+
async setFeedInLimit(unitId, limitW) {
|
|
1974
2187
|
if (limitW === null) {
|
|
1975
2188
|
// Remove limit: disable WMaxLim_Ena
|
|
1976
|
-
console.log(
|
|
1977
|
-
return this.writeInverterControls({ WMaxLim_Ena: sunspec_interfaces_js_1.SunspecEnableControl.DISABLED });
|
|
2189
|
+
console.log(`Removing feed-in limit (disabling WMaxLim_Ena) on unit ${unitId}`);
|
|
2190
|
+
return this.writeInverterControls(unitId, { WMaxLim_Ena: sunspec_interfaces_js_1.SunspecEnableControl.DISABLED });
|
|
1978
2191
|
}
|
|
1979
2192
|
// Read WMax from Model 121 to compute percentage
|
|
1980
|
-
const settings = await this.readInverterSettings();
|
|
2193
|
+
const settings = await this.readInverterSettings(unitId);
|
|
1981
2194
|
if (!settings || !settings.WMax) {
|
|
1982
|
-
console.error(
|
|
2195
|
+
console.error(`Cannot set feed-in limit on unit ${unitId}: unable to read WMax from Model 121`);
|
|
1983
2196
|
return false;
|
|
1984
2197
|
}
|
|
1985
2198
|
const pct = (limitW / settings.WMax) * 100;
|
|
1986
|
-
console.log(`Setting feed-in limit to ${limitW}W (${pct.toFixed(2)}% of WMax ${settings.WMax}W)`);
|
|
1987
|
-
return this.writeInverterControls({
|
|
2199
|
+
console.log(`Setting feed-in limit to ${limitW}W (${pct.toFixed(2)}% of WMax ${settings.WMax}W) on unit ${unitId}`);
|
|
2200
|
+
return this.writeInverterControls(unitId, {
|
|
1988
2201
|
WMaxLimPct: pct,
|
|
1989
2202
|
WMaxLim_Ena: sunspec_interfaces_js_1.SunspecEnableControl.ENABLED
|
|
1990
2203
|
});
|