@enyo-energy/sunspec-sdk 0.0.49 → 0.0.51
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 +12 -9
- package/dist/cjs/sunspec-devices.d.cts +9 -6
- package/dist/cjs/sunspec-modbus-client.cjs +144 -101
- package/dist/cjs/sunspec-modbus-client.d.cts +18 -2
- package/dist/cjs/version.cjs +1 -1
- package/dist/cjs/version.d.cts +1 -1
- package/dist/sunspec-devices.d.ts +9 -6
- package/dist/sunspec-devices.js +12 -9
- package/dist/sunspec-modbus-client.d.ts +18 -2
- package/dist/sunspec-modbus-client.js +144 -101
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -335,7 +335,7 @@ class SunspecInverter extends BaseSunspecDevice {
|
|
|
335
335
|
const dcStrings = this.mapMPPTToStrings(mpptDataList);
|
|
336
336
|
if (inverterData) {
|
|
337
337
|
const pvPowerW = dcStrings.reduce((sum, s) => sum + (s.powerW || 0), 0);
|
|
338
|
-
console.
|
|
338
|
+
console.debug(`Got PV Power from DC strings: ${pvPowerW}W`);
|
|
339
339
|
const inverterMessage = {
|
|
340
340
|
id: (0, node_crypto_1.randomUUID)(),
|
|
341
341
|
message: enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.InverterValuesUpdateV1,
|
|
@@ -425,19 +425,22 @@ class SunspecInverter extends BaseSunspecDevice {
|
|
|
425
425
|
}
|
|
426
426
|
/**
|
|
427
427
|
* Map a set bit in the Evt2 register. Reserved in the SunSpec spec, so
|
|
428
|
-
* the default
|
|
429
|
-
* vendor-specific
|
|
428
|
+
* the default returns `undefined` — Evt2 bits are not treated as errors.
|
|
429
|
+
* Override in a vendor-specific subclass to opt specific bits in as
|
|
430
|
+
* errors.
|
|
430
431
|
*/
|
|
431
|
-
mapEvt2Bit(
|
|
432
|
-
return
|
|
432
|
+
mapEvt2Bit(_bit) {
|
|
433
|
+
return undefined;
|
|
433
434
|
}
|
|
434
435
|
/**
|
|
435
436
|
* Map a set bit in one of the four vendor-specific event registers.
|
|
436
|
-
*
|
|
437
|
-
*
|
|
437
|
+
* Vendor bits are not standardized and many are informational rather
|
|
438
|
+
* than faults, so the default returns `undefined` and no error code is
|
|
439
|
+
* emitted. Override in a vendor-specific subclass to opt specific bits
|
|
440
|
+
* in as errors.
|
|
438
441
|
*/
|
|
439
|
-
mapVendorEventBit(
|
|
440
|
-
return
|
|
442
|
+
mapVendorEventBit(_registerIndex, _bit) {
|
|
443
|
+
return undefined;
|
|
441
444
|
}
|
|
442
445
|
hasErrorSetChanged(newCodeIds) {
|
|
443
446
|
const prev = this.errorState.activeCodes;
|
|
@@ -98,16 +98,19 @@ export declare class SunspecInverter extends BaseSunspecDevice {
|
|
|
98
98
|
protected mapEvt1Bit(bit: number): EnyoApplianceErrorCode | undefined;
|
|
99
99
|
/**
|
|
100
100
|
* Map a set bit in the Evt2 register. Reserved in the SunSpec spec, so
|
|
101
|
-
* the default
|
|
102
|
-
* vendor-specific
|
|
101
|
+
* the default returns `undefined` — Evt2 bits are not treated as errors.
|
|
102
|
+
* Override in a vendor-specific subclass to opt specific bits in as
|
|
103
|
+
* errors.
|
|
103
104
|
*/
|
|
104
|
-
protected mapEvt2Bit(
|
|
105
|
+
protected mapEvt2Bit(_bit: number): EnyoApplianceErrorCode | undefined;
|
|
105
106
|
/**
|
|
106
107
|
* Map a set bit in one of the four vendor-specific event registers.
|
|
107
|
-
*
|
|
108
|
-
*
|
|
108
|
+
* Vendor bits are not standardized and many are informational rather
|
|
109
|
+
* than faults, so the default returns `undefined` and no error code is
|
|
110
|
+
* emitted. Override in a vendor-specific subclass to opt specific bits
|
|
111
|
+
* in as errors.
|
|
109
112
|
*/
|
|
110
|
-
protected mapVendorEventBit(
|
|
113
|
+
protected mapVendorEventBit(_registerIndex: 1 | 2 | 3 | 4, _bit: number): EnyoApplianceErrorCode | undefined;
|
|
111
114
|
private hasErrorSetChanged;
|
|
112
115
|
private buildStatusMessage;
|
|
113
116
|
private storageKey;
|
|
@@ -36,6 +36,9 @@ class SunspecModbusClient {
|
|
|
36
36
|
openCount = 0;
|
|
37
37
|
closeCount = 0;
|
|
38
38
|
currentlyOpen = 0;
|
|
39
|
+
// Serializes connection-state transitions so concurrent callers cannot open a second
|
|
40
|
+
// TCP socket while the first is still alive (some Modbus devices allow only one client).
|
|
41
|
+
operationChain = Promise.resolve();
|
|
39
42
|
constructor(energyApp) {
|
|
40
43
|
this.energyApp = energyApp;
|
|
41
44
|
this.connectionHealth = new EnergyAppModbusConnectionHealth_js_1.EnergyAppModbusConnectionHealth();
|
|
@@ -49,25 +52,23 @@ class SunspecModbusClient {
|
|
|
49
52
|
* @param secondaryHost Optional secondary host (ipAddress) for fallback during reconnection
|
|
50
53
|
*/
|
|
51
54
|
async connect(host, port = 502, unitId = 1, secondaryHost) {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
55
|
+
return this.withConnectionLock(async () => {
|
|
56
|
+
const sameParams = this.connected
|
|
57
|
+
&& this.connectionParams !== null
|
|
58
|
+
&& this.connectionParams.primaryHost === host
|
|
59
|
+
&& this.connectionParams.port === port
|
|
60
|
+
&& this.connectionParams.unitId === unitId;
|
|
61
|
+
if (sameParams) {
|
|
62
|
+
console.debug(`connect(): already connected to ${host}:${port} unit ${unitId}, skipping`);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (this.connected || this.modbusClient) {
|
|
66
|
+
await this._closeSocket();
|
|
67
|
+
}
|
|
68
|
+
this.connectionParams = { primaryHost: host, secondaryHost, port, unitId };
|
|
69
|
+
await this._openSocket(host, port, unitId);
|
|
70
|
+
console.log(`Connected to Sunspec device at ${host}:${port} unit ${unitId} (opens=${this.openCount}, closes=${this.closeCount}, current=${this.currentlyOpen})`);
|
|
62
71
|
});
|
|
63
|
-
// Create fault-tolerant reader with connection health monitoring
|
|
64
|
-
if (this.modbusClient) {
|
|
65
|
-
this.faultTolerantReader = new EnergyAppModbusFaultTolerantReader_js_1.EnergyAppModbusFaultTolerantReader(this.modbusClient, this.connectionHealth);
|
|
66
|
-
}
|
|
67
|
-
this.connected = true;
|
|
68
|
-
this.connectionHealth.recordSuccess();
|
|
69
|
-
this.recordOpen();
|
|
70
|
-
console.log(`Connected to Sunspec device at ${host}:${port} unit ${unitId} (opens=${this.openCount}, closes=${this.closeCount}, current=${this.currentlyOpen})`);
|
|
71
72
|
}
|
|
72
73
|
/**
|
|
73
74
|
* Disconnect from Modbus device.
|
|
@@ -76,18 +77,16 @@ class SunspecModbusClient {
|
|
|
76
77
|
* They will be overwritten by the next connect() call anyway.
|
|
77
78
|
*/
|
|
78
79
|
async disconnect() {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
this.connected = false;
|
|
84
|
-
this.discoveredModels.clear();
|
|
85
|
-
this.recordClose();
|
|
80
|
+
return this.withConnectionLock(async () => {
|
|
81
|
+
if (!this.modbusClient && !this.connected) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
86
84
|
const host = this.connectionParams?.primaryHost ?? 'unknown';
|
|
87
85
|
const port = this.connectionParams?.port ?? 0;
|
|
88
86
|
const unitId = this.connectionParams?.unitId ?? 0;
|
|
87
|
+
await this._closeSocket();
|
|
89
88
|
console.log(`Disconnected from Sunspec device at ${host}:${port} unit ${unitId} (opens=${this.openCount}, closes=${this.closeCount}, current=${this.currentlyOpen})`);
|
|
90
|
-
}
|
|
89
|
+
});
|
|
91
90
|
}
|
|
92
91
|
/**
|
|
93
92
|
* Reconnect using stored connection parameters
|
|
@@ -95,78 +94,122 @@ class SunspecModbusClient {
|
|
|
95
94
|
* Returns true if reconnection was successful, false otherwise
|
|
96
95
|
*/
|
|
97
96
|
async reconnect() {
|
|
98
|
-
|
|
99
|
-
|
|
97
|
+
return this.withConnectionLock(async () => {
|
|
98
|
+
if (!this.connectionParams) {
|
|
99
|
+
console.error('Cannot reconnect: no connection parameters stored. Call connect() first.');
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
const { primaryHost, secondaryHost, port, unitId } = this.connectionParams;
|
|
103
|
+
console.log(`Attempting to reconnect to primary host ${primaryHost}:${port} unit ${unitId}...`);
|
|
104
|
+
const primarySuccess = await this.attemptConnection(primaryHost, port, unitId);
|
|
105
|
+
if (primarySuccess) {
|
|
106
|
+
console.log(`Successfully reconnected to primary host ${primaryHost}:${port} unit ${unitId}`);
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
if (secondaryHost && secondaryHost !== primaryHost) {
|
|
110
|
+
console.log(`Primary host failed, attempting secondary host ${secondaryHost}:${port} unit ${unitId}...`);
|
|
111
|
+
const secondarySuccess = await this.attemptConnection(secondaryHost, port, unitId);
|
|
112
|
+
if (secondarySuccess) {
|
|
113
|
+
console.log(`Successfully reconnected to secondary host ${secondaryHost}:${port} unit ${unitId}`);
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
console.error(`Reconnection failed to all available hosts`);
|
|
100
118
|
return false;
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Attempt to establish a connection to a specific host. Caller must hold the connection lock.
|
|
123
|
+
* Returns true if successful, false otherwise.
|
|
124
|
+
*/
|
|
125
|
+
async attemptConnection(host, port, unitId) {
|
|
126
|
+
if (this.connected || this.modbusClient) {
|
|
127
|
+
await this._closeSocket();
|
|
101
128
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const primarySuccess = await this.attemptConnection(primaryHost, port, unitId);
|
|
106
|
-
if (primarySuccess) {
|
|
107
|
-
console.log(`Successfully reconnected to primary host ${primaryHost}:${port} unit ${unitId}`);
|
|
129
|
+
try {
|
|
130
|
+
await this._openSocket(host, port, unitId);
|
|
131
|
+
console.log(`Connection attempt to ${host}:${port} unit ${unitId} succeeded (opens=${this.openCount}, closes=${this.closeCount}, current=${this.currentlyOpen})`);
|
|
108
132
|
return true;
|
|
109
133
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
const secondarySuccess = await this.attemptConnection(secondaryHost, port, unitId);
|
|
114
|
-
if (secondarySuccess) {
|
|
115
|
-
console.log(`Successfully reconnected to secondary host ${secondaryHost}:${port} unit ${unitId}`);
|
|
116
|
-
return true;
|
|
117
|
-
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
console.error(`Connection attempt to ${host}:${port} failed: ${error}`);
|
|
136
|
+
return false;
|
|
118
137
|
}
|
|
119
|
-
console.error(`Reconnection failed to all available hosts`);
|
|
120
|
-
this.connected = false;
|
|
121
|
-
return false;
|
|
122
138
|
}
|
|
123
139
|
/**
|
|
124
|
-
*
|
|
125
|
-
*
|
|
140
|
+
* Open a new Modbus socket and wire up the fault-tolerant reader. On any failure, the
|
|
141
|
+
* just-opened socket (if any) is closed so we never leak. Caller must hold the connection
|
|
142
|
+
* lock and ensure no prior socket is open (use _closeSocket first).
|
|
126
143
|
*/
|
|
127
|
-
async
|
|
144
|
+
async _openSocket(host, port, unitId) {
|
|
145
|
+
let candidate = null;
|
|
128
146
|
try {
|
|
129
|
-
|
|
130
|
-
if (this.modbusClient) {
|
|
131
|
-
const wasConnected = this.connected;
|
|
132
|
-
try {
|
|
133
|
-
await this.modbusClient.disconnect();
|
|
134
|
-
}
|
|
135
|
-
catch (e) {
|
|
136
|
-
// Ignore disconnect errors during reconnection
|
|
137
|
-
}
|
|
138
|
-
this.modbusClient = null;
|
|
139
|
-
this.faultTolerantReader = null;
|
|
140
|
-
if (wasConnected) {
|
|
141
|
-
this.connected = false;
|
|
142
|
-
this.recordClose();
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
// Attempt connection
|
|
146
|
-
this.modbusClient = await this.energyApp.useModbus().connect({
|
|
147
|
+
candidate = await this.energyApp.useModbus().connect({
|
|
147
148
|
host,
|
|
148
149
|
port,
|
|
149
150
|
unitId,
|
|
150
151
|
timeout: 5000
|
|
151
152
|
});
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
this.faultTolerantReader = new EnergyAppModbusFaultTolerantReader_js_1.EnergyAppModbusFaultTolerantReader(this.modbusClient, this.connectionHealth);
|
|
153
|
+
if (!candidate) {
|
|
154
|
+
throw new Error(`useModbus().connect returned null for ${host}:${port} unit ${unitId}`);
|
|
155
155
|
}
|
|
156
|
+
this.faultTolerantReader = new EnergyAppModbusFaultTolerantReader_js_1.EnergyAppModbusFaultTolerantReader(candidate, this.connectionHealth);
|
|
157
|
+
this.modbusClient = candidate;
|
|
156
158
|
this.connected = true;
|
|
157
159
|
this.connectionHealth.recordSuccess();
|
|
158
160
|
this.recordOpen();
|
|
159
|
-
console.log(`Connection attempt to ${host}:${port} unit ${unitId} succeeded (opens=${this.openCount}, closes=${this.closeCount}, current=${this.currentlyOpen})`);
|
|
160
|
-
return true;
|
|
161
161
|
}
|
|
162
|
-
catch (
|
|
163
|
-
|
|
164
|
-
|
|
162
|
+
catch (err) {
|
|
163
|
+
if (candidate) {
|
|
164
|
+
try {
|
|
165
|
+
await candidate.disconnect();
|
|
166
|
+
}
|
|
167
|
+
catch { /* ignore */ }
|
|
168
|
+
}
|
|
169
|
+
this.modbusClient = null;
|
|
170
|
+
this.faultTolerantReader = null;
|
|
171
|
+
this.connected = false;
|
|
172
|
+
throw err;
|
|
165
173
|
}
|
|
166
174
|
}
|
|
175
|
+
/**
|
|
176
|
+
* Close the current Modbus socket if any. Idempotent. Caller must hold the connection lock.
|
|
177
|
+
*/
|
|
178
|
+
async _closeSocket() {
|
|
179
|
+
if (!this.modbusClient && !this.connected)
|
|
180
|
+
return;
|
|
181
|
+
const client = this.modbusClient;
|
|
182
|
+
const wasConnected = this.connected;
|
|
183
|
+
this.modbusClient = null;
|
|
184
|
+
this.faultTolerantReader = null;
|
|
185
|
+
this.connected = false;
|
|
186
|
+
this.discoveredModels.clear();
|
|
187
|
+
if (client) {
|
|
188
|
+
try {
|
|
189
|
+
await client.disconnect();
|
|
190
|
+
}
|
|
191
|
+
catch { /* ignore */ }
|
|
192
|
+
}
|
|
193
|
+
if (wasConnected)
|
|
194
|
+
this.recordClose();
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Run `fn` with exclusive access to connection-state transitions. Subsequent calls queue
|
|
198
|
+
* behind any in-flight one. A rejected `fn` does not poison the chain for later callers.
|
|
199
|
+
*/
|
|
200
|
+
withConnectionLock(fn) {
|
|
201
|
+
const run = this.operationChain.then(fn, fn);
|
|
202
|
+
this.operationChain = run.catch(() => undefined);
|
|
203
|
+
return run;
|
|
204
|
+
}
|
|
167
205
|
recordOpen() {
|
|
168
206
|
this.openCount++;
|
|
169
207
|
this.currentlyOpen++;
|
|
208
|
+
if (this.currentlyOpen > 1) {
|
|
209
|
+
console.error(`SunspecModbusClient: currentlyOpen=${this.currentlyOpen} after open ` +
|
|
210
|
+
`(opens=${this.openCount}, closes=${this.closeCount}). ` +
|
|
211
|
+
`This indicates a code path bypassing the connection lock — please investigate.`);
|
|
212
|
+
}
|
|
170
213
|
}
|
|
171
214
|
recordClose() {
|
|
172
215
|
this.closeCount++;
|
|
@@ -516,7 +559,7 @@ class SunspecModbusClient {
|
|
|
516
559
|
async readInverterData() {
|
|
517
560
|
const model = this.findModel(sunspec_interfaces_js_1.SunspecModelId.Inverter3Phase);
|
|
518
561
|
if (!model) {
|
|
519
|
-
console.
|
|
562
|
+
console.debug('Inverter model 103 not found, trying single phase model 101');
|
|
520
563
|
const singlePhaseModel = this.findModel(sunspec_interfaces_js_1.SunspecModelId.InverterSinglePhase);
|
|
521
564
|
if (!singlePhaseModel) {
|
|
522
565
|
console.error('No inverter model found');
|
|
@@ -525,10 +568,10 @@ class SunspecModbusClient {
|
|
|
525
568
|
console.warn('IMPORTANT: Working with single-phase inverter, but 3-phase is expected!');
|
|
526
569
|
return this.readSinglePhaseInverterData(singlePhaseModel);
|
|
527
570
|
}
|
|
528
|
-
console.
|
|
571
|
+
console.debug(`Found 3-phase inverter model 103 at address ${model.address} with length ${model.length}`);
|
|
529
572
|
try {
|
|
530
573
|
// Read entire model block in a single Modbus call
|
|
531
|
-
console.
|
|
574
|
+
console.debug(`Reading Inverter Data from Model ${model.id} at base address: ${model.address}`);
|
|
532
575
|
const buffer = await this.readModelBlock(model);
|
|
533
576
|
// Extract all scale factors from buffer
|
|
534
577
|
const scaleFactors = this.extractInverterScaleFactors(buffer);
|
|
@@ -612,7 +655,7 @@ class SunspecModbusClient {
|
|
|
612
655
|
*/
|
|
613
656
|
async readSinglePhaseInverterData(model) {
|
|
614
657
|
try {
|
|
615
|
-
console.
|
|
658
|
+
console.debug(`Reading Single-Phase Inverter Data from Model 101 at base address: ${model.address}`);
|
|
616
659
|
// Read entire model block in a single Modbus call
|
|
617
660
|
const buffer = await this.readModelBlock(model);
|
|
618
661
|
// Extract scale factors from buffer
|
|
@@ -766,7 +809,7 @@ class SunspecModbusClient {
|
|
|
766
809
|
async readMPPTScaleFactors() {
|
|
767
810
|
const model = this.findModel(sunspec_interfaces_js_1.SunspecModelId.MPPT);
|
|
768
811
|
if (!model) {
|
|
769
|
-
console.
|
|
812
|
+
console.debug('MPPT model 160 not found');
|
|
770
813
|
return null;
|
|
771
814
|
}
|
|
772
815
|
try {
|
|
@@ -800,7 +843,7 @@ class SunspecModbusClient {
|
|
|
800
843
|
this.isUnimplementedValue(dcCurrentRaw, 'uint16') &&
|
|
801
844
|
this.isUnimplementedValue(dcVoltageRaw, 'uint16') &&
|
|
802
845
|
this.isUnimplementedValue(dcPowerRaw, 'uint16')) {
|
|
803
|
-
console.
|
|
846
|
+
console.debug(`MPPT module ${moduleId} appears to be unconnected (all values are 0xFFFF)`);
|
|
804
847
|
return null;
|
|
805
848
|
}
|
|
806
849
|
const temperatureScaleFactor = -1;
|
|
@@ -838,7 +881,7 @@ class SunspecModbusClient {
|
|
|
838
881
|
async readMPPTData(moduleId = 1) {
|
|
839
882
|
const model = this.findModel(sunspec_interfaces_js_1.SunspecModelId.MPPT);
|
|
840
883
|
if (!model) {
|
|
841
|
-
console.
|
|
884
|
+
console.debug('MPPT model 160 not found');
|
|
842
885
|
return null;
|
|
843
886
|
}
|
|
844
887
|
try {
|
|
@@ -859,7 +902,7 @@ class SunspecModbusClient {
|
|
|
859
902
|
const mpptData = [];
|
|
860
903
|
const model = this.findModel(sunspec_interfaces_js_1.SunspecModelId.MPPT);
|
|
861
904
|
if (!model) {
|
|
862
|
-
console.
|
|
905
|
+
console.debug('MPPT model 160 not found');
|
|
863
906
|
return [];
|
|
864
907
|
}
|
|
865
908
|
try {
|
|
@@ -871,16 +914,16 @@ class SunspecModbusClient {
|
|
|
871
914
|
const count = this.extractValue(buffer, 8, 'uint16');
|
|
872
915
|
if (!this.isUnimplementedValue(count, 'uint16') && count > 0 && count <= 20) {
|
|
873
916
|
moduleCount = count;
|
|
874
|
-
console.
|
|
917
|
+
console.debug(`MPPT module count from register 8: ${moduleCount}`);
|
|
875
918
|
}
|
|
876
919
|
else {
|
|
877
|
-
console.
|
|
920
|
+
console.debug(`Invalid or unimplemented module count (${count}), using default: ${moduleCount}`);
|
|
878
921
|
}
|
|
879
922
|
// Extract each MPPT module from the same buffer
|
|
880
923
|
for (let i = 1; i <= moduleCount; i++) {
|
|
881
924
|
try {
|
|
882
925
|
const data = this.extractMPPTModuleData(buffer, model, i, scaleFactors);
|
|
883
|
-
console.
|
|
926
|
+
console.debug(`MPPT ${i} has id ${data?.id} (${data?.stringId}) with ${data?.dcPower}W`);
|
|
884
927
|
if (data &&
|
|
885
928
|
(data.dcCurrent !== undefined ||
|
|
886
929
|
data.dcVoltage !== undefined ||
|
|
@@ -1028,10 +1071,10 @@ class SunspecModbusClient {
|
|
|
1028
1071
|
async readBatteryBaseData() {
|
|
1029
1072
|
const model = this.findModel(sunspec_interfaces_js_1.SunspecModelId.BatteryBase);
|
|
1030
1073
|
if (!model) {
|
|
1031
|
-
console.
|
|
1074
|
+
console.debug('Battery Base model 802 not found');
|
|
1032
1075
|
return null;
|
|
1033
1076
|
}
|
|
1034
|
-
console.
|
|
1077
|
+
console.debug(`Reading Battery Base Data from Model 802 at base address: ${model.address}`);
|
|
1035
1078
|
try {
|
|
1036
1079
|
// Read entire model block in a single Modbus call
|
|
1037
1080
|
const buffer = await this.readModelBlock(model);
|
|
@@ -1174,14 +1217,14 @@ class SunspecModbusClient {
|
|
|
1174
1217
|
model = this.findModel(sunspec_interfaces_js_1.SunspecModelId.BatteryControl);
|
|
1175
1218
|
}
|
|
1176
1219
|
if (!model) {
|
|
1177
|
-
console.
|
|
1220
|
+
console.debug('No battery model found');
|
|
1178
1221
|
return null;
|
|
1179
1222
|
}
|
|
1180
|
-
console.
|
|
1223
|
+
console.debug(`Reading Battery Data from Model ${model.id} at base address: ${model.address}`);
|
|
1181
1224
|
try {
|
|
1182
1225
|
if (model.id === 124) {
|
|
1183
1226
|
// Model 124: Basic Storage Controls
|
|
1184
|
-
console.
|
|
1227
|
+
console.debug('Using Model 124 (Basic Storage Controls)');
|
|
1185
1228
|
// Read entire model block in a single Modbus call
|
|
1186
1229
|
const buffer = await this.readModelBlock(model);
|
|
1187
1230
|
// Extract scale factors from buffer (offsets 18-25)
|
|
@@ -1271,19 +1314,19 @@ class SunspecModbusClient {
|
|
|
1271
1314
|
// Calculate charge/discharge power if rates are available
|
|
1272
1315
|
if (data.inWRte !== undefined && data.wChaMax !== undefined) {
|
|
1273
1316
|
data.chargePower = (data.inWRte / 100) * data.wChaMax;
|
|
1274
|
-
console.
|
|
1317
|
+
console.debug(`Calculated Charge Power: (inWRte: ${data.inWRte}, wChaMax: ${data.wChaMax}) ${data.chargePower?.toFixed(2)} W`);
|
|
1275
1318
|
}
|
|
1276
1319
|
if (data.outWRte !== undefined && data.wChaMax !== undefined) {
|
|
1277
1320
|
// Assuming WDisChaMax is similar to WChaMax for simplicity
|
|
1278
1321
|
data.dischargePower = Math.abs((data.outWRte / 100) * data.wChaMax);
|
|
1279
|
-
console.
|
|
1322
|
+
console.debug(`Calculated Discharge Power (inWRte: ${data.outWRte}, wChaMax: ${data.wChaMax}): ${data.dischargePower?.toFixed(2)} W`);
|
|
1280
1323
|
}
|
|
1281
1324
|
console.debug('[Model 124] Battery Data:', data);
|
|
1282
1325
|
return data;
|
|
1283
1326
|
}
|
|
1284
1327
|
else if (model.id === 802) {
|
|
1285
1328
|
// Model 802: Battery Base
|
|
1286
|
-
console.
|
|
1329
|
+
console.debug('Using Model 802 (Battery Base)');
|
|
1287
1330
|
const baseData = await this.readBatteryBaseData();
|
|
1288
1331
|
if (!baseData) {
|
|
1289
1332
|
return null;
|
|
@@ -1320,7 +1363,7 @@ class SunspecModbusClient {
|
|
|
1320
1363
|
}
|
|
1321
1364
|
else {
|
|
1322
1365
|
// Handle other battery models (803) if needed
|
|
1323
|
-
console.
|
|
1366
|
+
console.debug(`Battery Model ${model.id} reading not yet implemented`);
|
|
1324
1367
|
return {
|
|
1325
1368
|
blockNumber: model.id,
|
|
1326
1369
|
blockAddress: model.address,
|
|
@@ -1492,13 +1535,13 @@ class SunspecModbusClient {
|
|
|
1492
1535
|
model = this.findModel(sunspec_interfaces_js_1.SunspecModelId.MeterSinglePhase);
|
|
1493
1536
|
}
|
|
1494
1537
|
if (!model) {
|
|
1495
|
-
console.
|
|
1538
|
+
console.debug('No meter model found');
|
|
1496
1539
|
return null;
|
|
1497
1540
|
}
|
|
1498
|
-
console.
|
|
1541
|
+
console.debug(`Reading Meter Data from Model ${model.id} at base address: ${model.address}`);
|
|
1499
1542
|
try {
|
|
1500
1543
|
// Different meter models have different register offsets
|
|
1501
|
-
console.
|
|
1544
|
+
console.debug(`Meter is Model ${model.id}`);
|
|
1502
1545
|
let powerOffset;
|
|
1503
1546
|
let powerSFOffset;
|
|
1504
1547
|
let freqOffset;
|
|
@@ -1508,7 +1551,7 @@ class SunspecModbusClient {
|
|
|
1508
1551
|
let energySFOffset;
|
|
1509
1552
|
switch (model.id) {
|
|
1510
1553
|
case 203: // 3-phase meter (Delta)
|
|
1511
|
-
console.
|
|
1554
|
+
console.debug('Using Model 203 (3-Phase Meter Delta) offsets');
|
|
1512
1555
|
powerOffset = 18; // W - Total Real Power (int16)
|
|
1513
1556
|
powerSFOffset = 22; // W_SF - Power scale factor
|
|
1514
1557
|
freqOffset = 16; // Hz - Frequency (uint16)
|
|
@@ -1518,7 +1561,7 @@ class SunspecModbusClient {
|
|
|
1518
1561
|
energySFOffset = 54; // TotWh_SF - Total Energy scale factor
|
|
1519
1562
|
break;
|
|
1520
1563
|
case 204: // 3-phase meter (Wye)
|
|
1521
|
-
console.
|
|
1564
|
+
console.debug('Using Model 204 (3-Phase Meter Wye) offsets');
|
|
1522
1565
|
powerOffset = 18; // W - Total Real Power (int16)
|
|
1523
1566
|
powerSFOffset = 22; // W_SF - Power scale factor
|
|
1524
1567
|
freqOffset = 16; // Hz - Frequency (uint16)
|
|
@@ -1529,7 +1572,7 @@ class SunspecModbusClient {
|
|
|
1529
1572
|
break;
|
|
1530
1573
|
case 201: // Single-phase meter
|
|
1531
1574
|
default:
|
|
1532
|
-
console.
|
|
1575
|
+
console.debug('Using Model 201 (Single-Phase Meter) offsets');
|
|
1533
1576
|
powerOffset = 18; // W - Total Real Power (int16)
|
|
1534
1577
|
powerSFOffset = 22; // W_SF - Power scale factor
|
|
1535
1578
|
freqOffset = 16; // Hz - Frequency (uint16)
|
|
@@ -1651,7 +1694,7 @@ class SunspecModbusClient {
|
|
|
1651
1694
|
async readInverterSettings() {
|
|
1652
1695
|
const model = this.findModel(sunspec_interfaces_js_1.SunspecModelId.Settings);
|
|
1653
1696
|
if (!model) {
|
|
1654
|
-
console.
|
|
1697
|
+
console.debug('Settings model 121 not found');
|
|
1655
1698
|
return null;
|
|
1656
1699
|
}
|
|
1657
1700
|
try {
|
|
@@ -32,6 +32,7 @@ export declare class SunspecModbusClient {
|
|
|
32
32
|
private openCount;
|
|
33
33
|
private closeCount;
|
|
34
34
|
private currentlyOpen;
|
|
35
|
+
private operationChain;
|
|
35
36
|
constructor(energyApp: EnergyApp);
|
|
36
37
|
/**
|
|
37
38
|
* Connect to Modbus device
|
|
@@ -55,10 +56,25 @@ export declare class SunspecModbusClient {
|
|
|
55
56
|
*/
|
|
56
57
|
reconnect(): Promise<boolean>;
|
|
57
58
|
/**
|
|
58
|
-
* Attempt to establish a connection to a specific host
|
|
59
|
-
* Returns true if successful, false otherwise
|
|
59
|
+
* Attempt to establish a connection to a specific host. Caller must hold the connection lock.
|
|
60
|
+
* Returns true if successful, false otherwise.
|
|
60
61
|
*/
|
|
61
62
|
private attemptConnection;
|
|
63
|
+
/**
|
|
64
|
+
* Open a new Modbus socket and wire up the fault-tolerant reader. On any failure, the
|
|
65
|
+
* just-opened socket (if any) is closed so we never leak. Caller must hold the connection
|
|
66
|
+
* lock and ensure no prior socket is open (use _closeSocket first).
|
|
67
|
+
*/
|
|
68
|
+
private _openSocket;
|
|
69
|
+
/**
|
|
70
|
+
* Close the current Modbus socket if any. Idempotent. Caller must hold the connection lock.
|
|
71
|
+
*/
|
|
72
|
+
private _closeSocket;
|
|
73
|
+
/**
|
|
74
|
+
* Run `fn` with exclusive access to connection-state transitions. Subsequent calls queue
|
|
75
|
+
* behind any in-flight one. A rejected `fn` does not poison the chain for later callers.
|
|
76
|
+
*/
|
|
77
|
+
private withConnectionLock;
|
|
62
78
|
private recordOpen;
|
|
63
79
|
private recordClose;
|
|
64
80
|
/**
|
package/dist/cjs/version.cjs
CHANGED
|
@@ -9,7 +9,7 @@ exports.getSdkVersion = getSdkVersion;
|
|
|
9
9
|
/**
|
|
10
10
|
* Current version of the enyo Energy App SDK.
|
|
11
11
|
*/
|
|
12
|
-
exports.SDK_VERSION = '0.0.
|
|
12
|
+
exports.SDK_VERSION = '0.0.51';
|
|
13
13
|
/**
|
|
14
14
|
* Gets the current SDK version.
|
|
15
15
|
* @returns The semantic version string of the SDK
|
package/dist/cjs/version.d.cts
CHANGED
|
@@ -98,16 +98,19 @@ export declare class SunspecInverter extends BaseSunspecDevice {
|
|
|
98
98
|
protected mapEvt1Bit(bit: number): EnyoApplianceErrorCode | undefined;
|
|
99
99
|
/**
|
|
100
100
|
* Map a set bit in the Evt2 register. Reserved in the SunSpec spec, so
|
|
101
|
-
* the default
|
|
102
|
-
* vendor-specific
|
|
101
|
+
* the default returns `undefined` — Evt2 bits are not treated as errors.
|
|
102
|
+
* Override in a vendor-specific subclass to opt specific bits in as
|
|
103
|
+
* errors.
|
|
103
104
|
*/
|
|
104
|
-
protected mapEvt2Bit(
|
|
105
|
+
protected mapEvt2Bit(_bit: number): EnyoApplianceErrorCode | undefined;
|
|
105
106
|
/**
|
|
106
107
|
* Map a set bit in one of the four vendor-specific event registers.
|
|
107
|
-
*
|
|
108
|
-
*
|
|
108
|
+
* Vendor bits are not standardized and many are informational rather
|
|
109
|
+
* than faults, so the default returns `undefined` and no error code is
|
|
110
|
+
* emitted. Override in a vendor-specific subclass to opt specific bits
|
|
111
|
+
* in as errors.
|
|
109
112
|
*/
|
|
110
|
-
protected mapVendorEventBit(
|
|
113
|
+
protected mapVendorEventBit(_registerIndex: 1 | 2 | 3 | 4, _bit: number): EnyoApplianceErrorCode | undefined;
|
|
111
114
|
private hasErrorSetChanged;
|
|
112
115
|
private buildStatusMessage;
|
|
113
116
|
private storageKey;
|
package/dist/sunspec-devices.js
CHANGED
|
@@ -331,7 +331,7 @@ export class SunspecInverter extends BaseSunspecDevice {
|
|
|
331
331
|
const dcStrings = this.mapMPPTToStrings(mpptDataList);
|
|
332
332
|
if (inverterData) {
|
|
333
333
|
const pvPowerW = dcStrings.reduce((sum, s) => sum + (s.powerW || 0), 0);
|
|
334
|
-
console.
|
|
334
|
+
console.debug(`Got PV Power from DC strings: ${pvPowerW}W`);
|
|
335
335
|
const inverterMessage = {
|
|
336
336
|
id: randomUUID(),
|
|
337
337
|
message: EnyoDataBusMessageEnum.InverterValuesUpdateV1,
|
|
@@ -421,19 +421,22 @@ export class SunspecInverter extends BaseSunspecDevice {
|
|
|
421
421
|
}
|
|
422
422
|
/**
|
|
423
423
|
* Map a set bit in the Evt2 register. Reserved in the SunSpec spec, so
|
|
424
|
-
* the default
|
|
425
|
-
* vendor-specific
|
|
424
|
+
* the default returns `undefined` — Evt2 bits are not treated as errors.
|
|
425
|
+
* Override in a vendor-specific subclass to opt specific bits in as
|
|
426
|
+
* errors.
|
|
426
427
|
*/
|
|
427
|
-
mapEvt2Bit(
|
|
428
|
-
return
|
|
428
|
+
mapEvt2Bit(_bit) {
|
|
429
|
+
return undefined;
|
|
429
430
|
}
|
|
430
431
|
/**
|
|
431
432
|
* Map a set bit in one of the four vendor-specific event registers.
|
|
432
|
-
*
|
|
433
|
-
*
|
|
433
|
+
* Vendor bits are not standardized and many are informational rather
|
|
434
|
+
* than faults, so the default returns `undefined` and no error code is
|
|
435
|
+
* emitted. Override in a vendor-specific subclass to opt specific bits
|
|
436
|
+
* in as errors.
|
|
434
437
|
*/
|
|
435
|
-
mapVendorEventBit(
|
|
436
|
-
return
|
|
438
|
+
mapVendorEventBit(_registerIndex, _bit) {
|
|
439
|
+
return undefined;
|
|
437
440
|
}
|
|
438
441
|
hasErrorSetChanged(newCodeIds) {
|
|
439
442
|
const prev = this.errorState.activeCodes;
|
|
@@ -32,6 +32,7 @@ export declare class SunspecModbusClient {
|
|
|
32
32
|
private openCount;
|
|
33
33
|
private closeCount;
|
|
34
34
|
private currentlyOpen;
|
|
35
|
+
private operationChain;
|
|
35
36
|
constructor(energyApp: EnergyApp);
|
|
36
37
|
/**
|
|
37
38
|
* Connect to Modbus device
|
|
@@ -55,10 +56,25 @@ export declare class SunspecModbusClient {
|
|
|
55
56
|
*/
|
|
56
57
|
reconnect(): Promise<boolean>;
|
|
57
58
|
/**
|
|
58
|
-
* Attempt to establish a connection to a specific host
|
|
59
|
-
* Returns true if successful, false otherwise
|
|
59
|
+
* Attempt to establish a connection to a specific host. Caller must hold the connection lock.
|
|
60
|
+
* Returns true if successful, false otherwise.
|
|
60
61
|
*/
|
|
61
62
|
private attemptConnection;
|
|
63
|
+
/**
|
|
64
|
+
* Open a new Modbus socket and wire up the fault-tolerant reader. On any failure, the
|
|
65
|
+
* just-opened socket (if any) is closed so we never leak. Caller must hold the connection
|
|
66
|
+
* lock and ensure no prior socket is open (use _closeSocket first).
|
|
67
|
+
*/
|
|
68
|
+
private _openSocket;
|
|
69
|
+
/**
|
|
70
|
+
* Close the current Modbus socket if any. Idempotent. Caller must hold the connection lock.
|
|
71
|
+
*/
|
|
72
|
+
private _closeSocket;
|
|
73
|
+
/**
|
|
74
|
+
* Run `fn` with exclusive access to connection-state transitions. Subsequent calls queue
|
|
75
|
+
* behind any in-flight one. A rejected `fn` does not poison the chain for later callers.
|
|
76
|
+
*/
|
|
77
|
+
private withConnectionLock;
|
|
62
78
|
private recordOpen;
|
|
63
79
|
private recordClose;
|
|
64
80
|
/**
|
|
@@ -33,6 +33,9 @@ export class SunspecModbusClient {
|
|
|
33
33
|
openCount = 0;
|
|
34
34
|
closeCount = 0;
|
|
35
35
|
currentlyOpen = 0;
|
|
36
|
+
// Serializes connection-state transitions so concurrent callers cannot open a second
|
|
37
|
+
// TCP socket while the first is still alive (some Modbus devices allow only one client).
|
|
38
|
+
operationChain = Promise.resolve();
|
|
36
39
|
constructor(energyApp) {
|
|
37
40
|
this.energyApp = energyApp;
|
|
38
41
|
this.connectionHealth = new EnergyAppModbusConnectionHealth();
|
|
@@ -46,25 +49,23 @@ export class SunspecModbusClient {
|
|
|
46
49
|
* @param secondaryHost Optional secondary host (ipAddress) for fallback during reconnection
|
|
47
50
|
*/
|
|
48
51
|
async connect(host, port = 502, unitId = 1, secondaryHost) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
52
|
+
return this.withConnectionLock(async () => {
|
|
53
|
+
const sameParams = this.connected
|
|
54
|
+
&& this.connectionParams !== null
|
|
55
|
+
&& this.connectionParams.primaryHost === host
|
|
56
|
+
&& this.connectionParams.port === port
|
|
57
|
+
&& this.connectionParams.unitId === unitId;
|
|
58
|
+
if (sameParams) {
|
|
59
|
+
console.debug(`connect(): already connected to ${host}:${port} unit ${unitId}, skipping`);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (this.connected || this.modbusClient) {
|
|
63
|
+
await this._closeSocket();
|
|
64
|
+
}
|
|
65
|
+
this.connectionParams = { primaryHost: host, secondaryHost, port, unitId };
|
|
66
|
+
await this._openSocket(host, port, unitId);
|
|
67
|
+
console.log(`Connected to Sunspec device at ${host}:${port} unit ${unitId} (opens=${this.openCount}, closes=${this.closeCount}, current=${this.currentlyOpen})`);
|
|
59
68
|
});
|
|
60
|
-
// Create fault-tolerant reader with connection health monitoring
|
|
61
|
-
if (this.modbusClient) {
|
|
62
|
-
this.faultTolerantReader = new EnergyAppModbusFaultTolerantReader(this.modbusClient, this.connectionHealth);
|
|
63
|
-
}
|
|
64
|
-
this.connected = true;
|
|
65
|
-
this.connectionHealth.recordSuccess();
|
|
66
|
-
this.recordOpen();
|
|
67
|
-
console.log(`Connected to Sunspec device at ${host}:${port} unit ${unitId} (opens=${this.openCount}, closes=${this.closeCount}, current=${this.currentlyOpen})`);
|
|
68
69
|
}
|
|
69
70
|
/**
|
|
70
71
|
* Disconnect from Modbus device.
|
|
@@ -73,18 +74,16 @@ export class SunspecModbusClient {
|
|
|
73
74
|
* They will be overwritten by the next connect() call anyway.
|
|
74
75
|
*/
|
|
75
76
|
async disconnect() {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
this.connected = false;
|
|
81
|
-
this.discoveredModels.clear();
|
|
82
|
-
this.recordClose();
|
|
77
|
+
return this.withConnectionLock(async () => {
|
|
78
|
+
if (!this.modbusClient && !this.connected) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
83
81
|
const host = this.connectionParams?.primaryHost ?? 'unknown';
|
|
84
82
|
const port = this.connectionParams?.port ?? 0;
|
|
85
83
|
const unitId = this.connectionParams?.unitId ?? 0;
|
|
84
|
+
await this._closeSocket();
|
|
86
85
|
console.log(`Disconnected from Sunspec device at ${host}:${port} unit ${unitId} (opens=${this.openCount}, closes=${this.closeCount}, current=${this.currentlyOpen})`);
|
|
87
|
-
}
|
|
86
|
+
});
|
|
88
87
|
}
|
|
89
88
|
/**
|
|
90
89
|
* Reconnect using stored connection parameters
|
|
@@ -92,78 +91,122 @@ export class SunspecModbusClient {
|
|
|
92
91
|
* Returns true if reconnection was successful, false otherwise
|
|
93
92
|
*/
|
|
94
93
|
async reconnect() {
|
|
95
|
-
|
|
96
|
-
|
|
94
|
+
return this.withConnectionLock(async () => {
|
|
95
|
+
if (!this.connectionParams) {
|
|
96
|
+
console.error('Cannot reconnect: no connection parameters stored. Call connect() first.');
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
const { primaryHost, secondaryHost, port, unitId } = this.connectionParams;
|
|
100
|
+
console.log(`Attempting to reconnect to primary host ${primaryHost}:${port} unit ${unitId}...`);
|
|
101
|
+
const primarySuccess = await this.attemptConnection(primaryHost, port, unitId);
|
|
102
|
+
if (primarySuccess) {
|
|
103
|
+
console.log(`Successfully reconnected to primary host ${primaryHost}:${port} unit ${unitId}`);
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
if (secondaryHost && secondaryHost !== primaryHost) {
|
|
107
|
+
console.log(`Primary host failed, attempting secondary host ${secondaryHost}:${port} unit ${unitId}...`);
|
|
108
|
+
const secondarySuccess = await this.attemptConnection(secondaryHost, port, unitId);
|
|
109
|
+
if (secondarySuccess) {
|
|
110
|
+
console.log(`Successfully reconnected to secondary host ${secondaryHost}:${port} unit ${unitId}`);
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
console.error(`Reconnection failed to all available hosts`);
|
|
97
115
|
return false;
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Attempt to establish a connection to a specific host. Caller must hold the connection lock.
|
|
120
|
+
* Returns true if successful, false otherwise.
|
|
121
|
+
*/
|
|
122
|
+
async attemptConnection(host, port, unitId) {
|
|
123
|
+
if (this.connected || this.modbusClient) {
|
|
124
|
+
await this._closeSocket();
|
|
98
125
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const primarySuccess = await this.attemptConnection(primaryHost, port, unitId);
|
|
103
|
-
if (primarySuccess) {
|
|
104
|
-
console.log(`Successfully reconnected to primary host ${primaryHost}:${port} unit ${unitId}`);
|
|
126
|
+
try {
|
|
127
|
+
await this._openSocket(host, port, unitId);
|
|
128
|
+
console.log(`Connection attempt to ${host}:${port} unit ${unitId} succeeded (opens=${this.openCount}, closes=${this.closeCount}, current=${this.currentlyOpen})`);
|
|
105
129
|
return true;
|
|
106
130
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const secondarySuccess = await this.attemptConnection(secondaryHost, port, unitId);
|
|
111
|
-
if (secondarySuccess) {
|
|
112
|
-
console.log(`Successfully reconnected to secondary host ${secondaryHost}:${port} unit ${unitId}`);
|
|
113
|
-
return true;
|
|
114
|
-
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
console.error(`Connection attempt to ${host}:${port} failed: ${error}`);
|
|
133
|
+
return false;
|
|
115
134
|
}
|
|
116
|
-
console.error(`Reconnection failed to all available hosts`);
|
|
117
|
-
this.connected = false;
|
|
118
|
-
return false;
|
|
119
135
|
}
|
|
120
136
|
/**
|
|
121
|
-
*
|
|
122
|
-
*
|
|
137
|
+
* Open a new Modbus socket and wire up the fault-tolerant reader. On any failure, the
|
|
138
|
+
* just-opened socket (if any) is closed so we never leak. Caller must hold the connection
|
|
139
|
+
* lock and ensure no prior socket is open (use _closeSocket first).
|
|
123
140
|
*/
|
|
124
|
-
async
|
|
141
|
+
async _openSocket(host, port, unitId) {
|
|
142
|
+
let candidate = null;
|
|
125
143
|
try {
|
|
126
|
-
|
|
127
|
-
if (this.modbusClient) {
|
|
128
|
-
const wasConnected = this.connected;
|
|
129
|
-
try {
|
|
130
|
-
await this.modbusClient.disconnect();
|
|
131
|
-
}
|
|
132
|
-
catch (e) {
|
|
133
|
-
// Ignore disconnect errors during reconnection
|
|
134
|
-
}
|
|
135
|
-
this.modbusClient = null;
|
|
136
|
-
this.faultTolerantReader = null;
|
|
137
|
-
if (wasConnected) {
|
|
138
|
-
this.connected = false;
|
|
139
|
-
this.recordClose();
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
// Attempt connection
|
|
143
|
-
this.modbusClient = await this.energyApp.useModbus().connect({
|
|
144
|
+
candidate = await this.energyApp.useModbus().connect({
|
|
144
145
|
host,
|
|
145
146
|
port,
|
|
146
147
|
unitId,
|
|
147
148
|
timeout: 5000
|
|
148
149
|
});
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
this.faultTolerantReader = new EnergyAppModbusFaultTolerantReader(this.modbusClient, this.connectionHealth);
|
|
150
|
+
if (!candidate) {
|
|
151
|
+
throw new Error(`useModbus().connect returned null for ${host}:${port} unit ${unitId}`);
|
|
152
152
|
}
|
|
153
|
+
this.faultTolerantReader = new EnergyAppModbusFaultTolerantReader(candidate, this.connectionHealth);
|
|
154
|
+
this.modbusClient = candidate;
|
|
153
155
|
this.connected = true;
|
|
154
156
|
this.connectionHealth.recordSuccess();
|
|
155
157
|
this.recordOpen();
|
|
156
|
-
console.log(`Connection attempt to ${host}:${port} unit ${unitId} succeeded (opens=${this.openCount}, closes=${this.closeCount}, current=${this.currentlyOpen})`);
|
|
157
|
-
return true;
|
|
158
158
|
}
|
|
159
|
-
catch (
|
|
160
|
-
|
|
161
|
-
|
|
159
|
+
catch (err) {
|
|
160
|
+
if (candidate) {
|
|
161
|
+
try {
|
|
162
|
+
await candidate.disconnect();
|
|
163
|
+
}
|
|
164
|
+
catch { /* ignore */ }
|
|
165
|
+
}
|
|
166
|
+
this.modbusClient = null;
|
|
167
|
+
this.faultTolerantReader = null;
|
|
168
|
+
this.connected = false;
|
|
169
|
+
throw err;
|
|
162
170
|
}
|
|
163
171
|
}
|
|
172
|
+
/**
|
|
173
|
+
* Close the current Modbus socket if any. Idempotent. Caller must hold the connection lock.
|
|
174
|
+
*/
|
|
175
|
+
async _closeSocket() {
|
|
176
|
+
if (!this.modbusClient && !this.connected)
|
|
177
|
+
return;
|
|
178
|
+
const client = this.modbusClient;
|
|
179
|
+
const wasConnected = this.connected;
|
|
180
|
+
this.modbusClient = null;
|
|
181
|
+
this.faultTolerantReader = null;
|
|
182
|
+
this.connected = false;
|
|
183
|
+
this.discoveredModels.clear();
|
|
184
|
+
if (client) {
|
|
185
|
+
try {
|
|
186
|
+
await client.disconnect();
|
|
187
|
+
}
|
|
188
|
+
catch { /* ignore */ }
|
|
189
|
+
}
|
|
190
|
+
if (wasConnected)
|
|
191
|
+
this.recordClose();
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Run `fn` with exclusive access to connection-state transitions. Subsequent calls queue
|
|
195
|
+
* behind any in-flight one. A rejected `fn` does not poison the chain for later callers.
|
|
196
|
+
*/
|
|
197
|
+
withConnectionLock(fn) {
|
|
198
|
+
const run = this.operationChain.then(fn, fn);
|
|
199
|
+
this.operationChain = run.catch(() => undefined);
|
|
200
|
+
return run;
|
|
201
|
+
}
|
|
164
202
|
recordOpen() {
|
|
165
203
|
this.openCount++;
|
|
166
204
|
this.currentlyOpen++;
|
|
205
|
+
if (this.currentlyOpen > 1) {
|
|
206
|
+
console.error(`SunspecModbusClient: currentlyOpen=${this.currentlyOpen} after open ` +
|
|
207
|
+
`(opens=${this.openCount}, closes=${this.closeCount}). ` +
|
|
208
|
+
`This indicates a code path bypassing the connection lock — please investigate.`);
|
|
209
|
+
}
|
|
167
210
|
}
|
|
168
211
|
recordClose() {
|
|
169
212
|
this.closeCount++;
|
|
@@ -513,7 +556,7 @@ export class SunspecModbusClient {
|
|
|
513
556
|
async readInverterData() {
|
|
514
557
|
const model = this.findModel(SunspecModelId.Inverter3Phase);
|
|
515
558
|
if (!model) {
|
|
516
|
-
console.
|
|
559
|
+
console.debug('Inverter model 103 not found, trying single phase model 101');
|
|
517
560
|
const singlePhaseModel = this.findModel(SunspecModelId.InverterSinglePhase);
|
|
518
561
|
if (!singlePhaseModel) {
|
|
519
562
|
console.error('No inverter model found');
|
|
@@ -522,10 +565,10 @@ export class SunspecModbusClient {
|
|
|
522
565
|
console.warn('IMPORTANT: Working with single-phase inverter, but 3-phase is expected!');
|
|
523
566
|
return this.readSinglePhaseInverterData(singlePhaseModel);
|
|
524
567
|
}
|
|
525
|
-
console.
|
|
568
|
+
console.debug(`Found 3-phase inverter model 103 at address ${model.address} with length ${model.length}`);
|
|
526
569
|
try {
|
|
527
570
|
// Read entire model block in a single Modbus call
|
|
528
|
-
console.
|
|
571
|
+
console.debug(`Reading Inverter Data from Model ${model.id} at base address: ${model.address}`);
|
|
529
572
|
const buffer = await this.readModelBlock(model);
|
|
530
573
|
// Extract all scale factors from buffer
|
|
531
574
|
const scaleFactors = this.extractInverterScaleFactors(buffer);
|
|
@@ -609,7 +652,7 @@ export class SunspecModbusClient {
|
|
|
609
652
|
*/
|
|
610
653
|
async readSinglePhaseInverterData(model) {
|
|
611
654
|
try {
|
|
612
|
-
console.
|
|
655
|
+
console.debug(`Reading Single-Phase Inverter Data from Model 101 at base address: ${model.address}`);
|
|
613
656
|
// Read entire model block in a single Modbus call
|
|
614
657
|
const buffer = await this.readModelBlock(model);
|
|
615
658
|
// Extract scale factors from buffer
|
|
@@ -763,7 +806,7 @@ export class SunspecModbusClient {
|
|
|
763
806
|
async readMPPTScaleFactors() {
|
|
764
807
|
const model = this.findModel(SunspecModelId.MPPT);
|
|
765
808
|
if (!model) {
|
|
766
|
-
console.
|
|
809
|
+
console.debug('MPPT model 160 not found');
|
|
767
810
|
return null;
|
|
768
811
|
}
|
|
769
812
|
try {
|
|
@@ -797,7 +840,7 @@ export class SunspecModbusClient {
|
|
|
797
840
|
this.isUnimplementedValue(dcCurrentRaw, 'uint16') &&
|
|
798
841
|
this.isUnimplementedValue(dcVoltageRaw, 'uint16') &&
|
|
799
842
|
this.isUnimplementedValue(dcPowerRaw, 'uint16')) {
|
|
800
|
-
console.
|
|
843
|
+
console.debug(`MPPT module ${moduleId} appears to be unconnected (all values are 0xFFFF)`);
|
|
801
844
|
return null;
|
|
802
845
|
}
|
|
803
846
|
const temperatureScaleFactor = -1;
|
|
@@ -835,7 +878,7 @@ export class SunspecModbusClient {
|
|
|
835
878
|
async readMPPTData(moduleId = 1) {
|
|
836
879
|
const model = this.findModel(SunspecModelId.MPPT);
|
|
837
880
|
if (!model) {
|
|
838
|
-
console.
|
|
881
|
+
console.debug('MPPT model 160 not found');
|
|
839
882
|
return null;
|
|
840
883
|
}
|
|
841
884
|
try {
|
|
@@ -856,7 +899,7 @@ export class SunspecModbusClient {
|
|
|
856
899
|
const mpptData = [];
|
|
857
900
|
const model = this.findModel(SunspecModelId.MPPT);
|
|
858
901
|
if (!model) {
|
|
859
|
-
console.
|
|
902
|
+
console.debug('MPPT model 160 not found');
|
|
860
903
|
return [];
|
|
861
904
|
}
|
|
862
905
|
try {
|
|
@@ -868,16 +911,16 @@ export class SunspecModbusClient {
|
|
|
868
911
|
const count = this.extractValue(buffer, 8, 'uint16');
|
|
869
912
|
if (!this.isUnimplementedValue(count, 'uint16') && count > 0 && count <= 20) {
|
|
870
913
|
moduleCount = count;
|
|
871
|
-
console.
|
|
914
|
+
console.debug(`MPPT module count from register 8: ${moduleCount}`);
|
|
872
915
|
}
|
|
873
916
|
else {
|
|
874
|
-
console.
|
|
917
|
+
console.debug(`Invalid or unimplemented module count (${count}), using default: ${moduleCount}`);
|
|
875
918
|
}
|
|
876
919
|
// Extract each MPPT module from the same buffer
|
|
877
920
|
for (let i = 1; i <= moduleCount; i++) {
|
|
878
921
|
try {
|
|
879
922
|
const data = this.extractMPPTModuleData(buffer, model, i, scaleFactors);
|
|
880
|
-
console.
|
|
923
|
+
console.debug(`MPPT ${i} has id ${data?.id} (${data?.stringId}) with ${data?.dcPower}W`);
|
|
881
924
|
if (data &&
|
|
882
925
|
(data.dcCurrent !== undefined ||
|
|
883
926
|
data.dcVoltage !== undefined ||
|
|
@@ -1025,10 +1068,10 @@ export class SunspecModbusClient {
|
|
|
1025
1068
|
async readBatteryBaseData() {
|
|
1026
1069
|
const model = this.findModel(SunspecModelId.BatteryBase);
|
|
1027
1070
|
if (!model) {
|
|
1028
|
-
console.
|
|
1071
|
+
console.debug('Battery Base model 802 not found');
|
|
1029
1072
|
return null;
|
|
1030
1073
|
}
|
|
1031
|
-
console.
|
|
1074
|
+
console.debug(`Reading Battery Base Data from Model 802 at base address: ${model.address}`);
|
|
1032
1075
|
try {
|
|
1033
1076
|
// Read entire model block in a single Modbus call
|
|
1034
1077
|
const buffer = await this.readModelBlock(model);
|
|
@@ -1171,14 +1214,14 @@ export class SunspecModbusClient {
|
|
|
1171
1214
|
model = this.findModel(SunspecModelId.BatteryControl);
|
|
1172
1215
|
}
|
|
1173
1216
|
if (!model) {
|
|
1174
|
-
console.
|
|
1217
|
+
console.debug('No battery model found');
|
|
1175
1218
|
return null;
|
|
1176
1219
|
}
|
|
1177
|
-
console.
|
|
1220
|
+
console.debug(`Reading Battery Data from Model ${model.id} at base address: ${model.address}`);
|
|
1178
1221
|
try {
|
|
1179
1222
|
if (model.id === 124) {
|
|
1180
1223
|
// Model 124: Basic Storage Controls
|
|
1181
|
-
console.
|
|
1224
|
+
console.debug('Using Model 124 (Basic Storage Controls)');
|
|
1182
1225
|
// Read entire model block in a single Modbus call
|
|
1183
1226
|
const buffer = await this.readModelBlock(model);
|
|
1184
1227
|
// Extract scale factors from buffer (offsets 18-25)
|
|
@@ -1268,19 +1311,19 @@ export class SunspecModbusClient {
|
|
|
1268
1311
|
// Calculate charge/discharge power if rates are available
|
|
1269
1312
|
if (data.inWRte !== undefined && data.wChaMax !== undefined) {
|
|
1270
1313
|
data.chargePower = (data.inWRte / 100) * data.wChaMax;
|
|
1271
|
-
console.
|
|
1314
|
+
console.debug(`Calculated Charge Power: (inWRte: ${data.inWRte}, wChaMax: ${data.wChaMax}) ${data.chargePower?.toFixed(2)} W`);
|
|
1272
1315
|
}
|
|
1273
1316
|
if (data.outWRte !== undefined && data.wChaMax !== undefined) {
|
|
1274
1317
|
// Assuming WDisChaMax is similar to WChaMax for simplicity
|
|
1275
1318
|
data.dischargePower = Math.abs((data.outWRte / 100) * data.wChaMax);
|
|
1276
|
-
console.
|
|
1319
|
+
console.debug(`Calculated Discharge Power (inWRte: ${data.outWRte}, wChaMax: ${data.wChaMax}): ${data.dischargePower?.toFixed(2)} W`);
|
|
1277
1320
|
}
|
|
1278
1321
|
console.debug('[Model 124] Battery Data:', data);
|
|
1279
1322
|
return data;
|
|
1280
1323
|
}
|
|
1281
1324
|
else if (model.id === 802) {
|
|
1282
1325
|
// Model 802: Battery Base
|
|
1283
|
-
console.
|
|
1326
|
+
console.debug('Using Model 802 (Battery Base)');
|
|
1284
1327
|
const baseData = await this.readBatteryBaseData();
|
|
1285
1328
|
if (!baseData) {
|
|
1286
1329
|
return null;
|
|
@@ -1317,7 +1360,7 @@ export class SunspecModbusClient {
|
|
|
1317
1360
|
}
|
|
1318
1361
|
else {
|
|
1319
1362
|
// Handle other battery models (803) if needed
|
|
1320
|
-
console.
|
|
1363
|
+
console.debug(`Battery Model ${model.id} reading not yet implemented`);
|
|
1321
1364
|
return {
|
|
1322
1365
|
blockNumber: model.id,
|
|
1323
1366
|
blockAddress: model.address,
|
|
@@ -1489,13 +1532,13 @@ export class SunspecModbusClient {
|
|
|
1489
1532
|
model = this.findModel(SunspecModelId.MeterSinglePhase);
|
|
1490
1533
|
}
|
|
1491
1534
|
if (!model) {
|
|
1492
|
-
console.
|
|
1535
|
+
console.debug('No meter model found');
|
|
1493
1536
|
return null;
|
|
1494
1537
|
}
|
|
1495
|
-
console.
|
|
1538
|
+
console.debug(`Reading Meter Data from Model ${model.id} at base address: ${model.address}`);
|
|
1496
1539
|
try {
|
|
1497
1540
|
// Different meter models have different register offsets
|
|
1498
|
-
console.
|
|
1541
|
+
console.debug(`Meter is Model ${model.id}`);
|
|
1499
1542
|
let powerOffset;
|
|
1500
1543
|
let powerSFOffset;
|
|
1501
1544
|
let freqOffset;
|
|
@@ -1505,7 +1548,7 @@ export class SunspecModbusClient {
|
|
|
1505
1548
|
let energySFOffset;
|
|
1506
1549
|
switch (model.id) {
|
|
1507
1550
|
case 203: // 3-phase meter (Delta)
|
|
1508
|
-
console.
|
|
1551
|
+
console.debug('Using Model 203 (3-Phase Meter Delta) offsets');
|
|
1509
1552
|
powerOffset = 18; // W - Total Real Power (int16)
|
|
1510
1553
|
powerSFOffset = 22; // W_SF - Power scale factor
|
|
1511
1554
|
freqOffset = 16; // Hz - Frequency (uint16)
|
|
@@ -1515,7 +1558,7 @@ export class SunspecModbusClient {
|
|
|
1515
1558
|
energySFOffset = 54; // TotWh_SF - Total Energy scale factor
|
|
1516
1559
|
break;
|
|
1517
1560
|
case 204: // 3-phase meter (Wye)
|
|
1518
|
-
console.
|
|
1561
|
+
console.debug('Using Model 204 (3-Phase Meter Wye) offsets');
|
|
1519
1562
|
powerOffset = 18; // W - Total Real Power (int16)
|
|
1520
1563
|
powerSFOffset = 22; // W_SF - Power scale factor
|
|
1521
1564
|
freqOffset = 16; // Hz - Frequency (uint16)
|
|
@@ -1526,7 +1569,7 @@ export class SunspecModbusClient {
|
|
|
1526
1569
|
break;
|
|
1527
1570
|
case 201: // Single-phase meter
|
|
1528
1571
|
default:
|
|
1529
|
-
console.
|
|
1572
|
+
console.debug('Using Model 201 (Single-Phase Meter) offsets');
|
|
1530
1573
|
powerOffset = 18; // W - Total Real Power (int16)
|
|
1531
1574
|
powerSFOffset = 22; // W_SF - Power scale factor
|
|
1532
1575
|
freqOffset = 16; // Hz - Frequency (uint16)
|
|
@@ -1648,7 +1691,7 @@ export class SunspecModbusClient {
|
|
|
1648
1691
|
async readInverterSettings() {
|
|
1649
1692
|
const model = this.findModel(SunspecModelId.Settings);
|
|
1650
1693
|
if (!model) {
|
|
1651
|
-
console.
|
|
1694
|
+
console.debug('Settings model 121 not found');
|
|
1652
1695
|
return null;
|
|
1653
1696
|
}
|
|
1654
1697
|
try {
|
package/dist/version.d.ts
CHANGED
package/dist/version.js
CHANGED