@enyo-energy/sunspec-sdk 0.0.61 → 0.0.63
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/connection-retry-manager.cjs +3 -2
- package/dist/cjs/connection-retry-manager.d.cts +3 -2
- package/dist/cjs/sunspec-devices.cjs +33 -0
- package/dist/cjs/sunspec-devices.d.cts +10 -0
- package/dist/cjs/sunspec-interfaces.cjs +2 -1
- package/dist/cjs/sunspec-modbus-client.cjs +80 -20
- package/dist/cjs/sunspec-modbus-client.d.cts +24 -0
- package/dist/cjs/version.cjs +1 -1
- package/dist/cjs/version.d.cts +1 -1
- package/dist/connection-retry-manager.d.ts +3 -2
- package/dist/connection-retry-manager.js +3 -2
- package/dist/sunspec-devices.d.ts +10 -0
- package/dist/sunspec-devices.js +33 -0
- package/dist/sunspec-interfaces.js +2 -1
- package/dist/sunspec-modbus-client.d.ts +24 -0
- package/dist/sunspec-modbus-client.js +80 -20
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -6,11 +6,12 @@
|
|
|
6
6
|
* Called on each readData() cycle; determines whether enough time has elapsed
|
|
7
7
|
* to attempt a reconnection based on the current retry phase.
|
|
8
8
|
*
|
|
9
|
-
* Default schedule:
|
|
9
|
+
* Default schedule (infinite retry, max 15m backoff):
|
|
10
10
|
* - Phase 1: every 10s for 1 minute
|
|
11
11
|
* - Phase 2: every 30s for 2 minutes
|
|
12
12
|
* - Phase 3: every 1m for 5 minutes
|
|
13
|
-
* - Phase 4: every 5m
|
|
13
|
+
* - Phase 4: every 5m for 10 minutes
|
|
14
|
+
* - Phase 5: every 15m forever
|
|
14
15
|
*/
|
|
15
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
17
|
exports.ConnectionRetryManager = void 0;
|
|
@@ -5,11 +5,12 @@
|
|
|
5
5
|
* Called on each readData() cycle; determines whether enough time has elapsed
|
|
6
6
|
* to attempt a reconnection based on the current retry phase.
|
|
7
7
|
*
|
|
8
|
-
* Default schedule:
|
|
8
|
+
* Default schedule (infinite retry, max 15m backoff):
|
|
9
9
|
* - Phase 1: every 10s for 1 minute
|
|
10
10
|
* - Phase 2: every 30s for 2 minutes
|
|
11
11
|
* - Phase 3: every 1m for 5 minutes
|
|
12
|
-
* - Phase 4: every 5m
|
|
12
|
+
* - Phase 4: every 5m for 10 minutes
|
|
13
|
+
* - Phase 5: every 15m forever
|
|
13
14
|
*/
|
|
14
15
|
import { IRetryConfig, IRetryPhase } from './sunspec-interfaces.cjs';
|
|
15
16
|
export declare class ConnectionRetryManager {
|
|
@@ -187,6 +187,24 @@ class BaseSunspecDevice {
|
|
|
187
187
|
async onConnectionFailure(_consecutiveFailures) {
|
|
188
188
|
// Default: no-op. SunspecInverter overrides to publish a faulted status.
|
|
189
189
|
}
|
|
190
|
+
/**
|
|
191
|
+
* Detect a silent read failure: the SDK's per-model readers swallow modbus errors and
|
|
192
|
+
* return null/[] rather than throwing, so a readData() cycle can complete "successfully"
|
|
193
|
+
* with no usable data while the underlying connection is broken. Once
|
|
194
|
+
* `connectionHealth.isHealthy()` flips to false, treat the device as offline so the
|
|
195
|
+
* appliance state is updated and the retry manager starts the backoff schedule.
|
|
196
|
+
*
|
|
197
|
+
* Returns true if the device was marked offline.
|
|
198
|
+
*/
|
|
199
|
+
async markOfflineIfUnhealthy() {
|
|
200
|
+
if (this.sunspecClient.isHealthy(this.unitId)) {
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
console.warn(`${this.constructor.name} ${this.applianceId}: connection unhealthy after read cycle ` +
|
|
204
|
+
`(consecutiveFailures=${this.sunspecClient.getConnectionHealth().getConsecutiveFailures()}) — marking offline`);
|
|
205
|
+
await this.markOffline();
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
190
208
|
/**
|
|
191
209
|
* Mark the device as offline: close the underlying socket so the next readData()
|
|
192
210
|
* cycle sees isConnected() === false and tryReconnect() can establish a fresh
|
|
@@ -338,6 +356,11 @@ class SunspecInverter extends BaseSunspecDevice {
|
|
|
338
356
|
const mpptDataList = await this.sunspecClient.readAllMPPTData(this.unitId);
|
|
339
357
|
const inverterSettings = await this.sunspecClient.readInverterSettings(this.unitId);
|
|
340
358
|
const dcStrings = this.mapMPPTToStrings(mpptDataList);
|
|
359
|
+
// SDK readers swallow modbus errors and return null/[]; detect that here so the
|
|
360
|
+
// appliance is marked offline and the retry manager starts its backoff.
|
|
361
|
+
if (await this.markOfflineIfUnhealthy()) {
|
|
362
|
+
return messages;
|
|
363
|
+
}
|
|
341
364
|
if (inverterData) {
|
|
342
365
|
const pvPowerW = dcStrings.reduce((sum, s) => sum + (s.powerW || 0), 0);
|
|
343
366
|
console.debug(`Got PV Power from DC strings: ${pvPowerW}W`);
|
|
@@ -806,6 +829,11 @@ class SunspecBattery extends BaseSunspecDevice {
|
|
|
806
829
|
const batteryData = await this.sunspecClient.readBatteryData(this.unitId);
|
|
807
830
|
const mpptDataList = await this.sunspecClient.readAllMPPTData(this.unitId);
|
|
808
831
|
const mpptBatteryPowerW = this.extractBatteryPowerFromMPPT(mpptDataList);
|
|
832
|
+
// SDK readers swallow modbus errors and return null/[]; detect that here so the
|
|
833
|
+
// appliance is marked offline and the retry manager starts its backoff.
|
|
834
|
+
if (await this.markOfflineIfUnhealthy()) {
|
|
835
|
+
return messages;
|
|
836
|
+
}
|
|
809
837
|
if (batteryData) {
|
|
810
838
|
const advancedBatteryModel = this.sunspecClient.findModel(this.unitId, 801);
|
|
811
839
|
const batteryBaseModel = this.sunspecClient.findModel(this.unitId, sunspec_interfaces_js_1.SunspecModelId.BatteryBase);
|
|
@@ -1291,6 +1319,11 @@ class SunspecMeter extends BaseSunspecDevice {
|
|
|
1291
1319
|
try {
|
|
1292
1320
|
// Read meter data
|
|
1293
1321
|
const meterData = await this.sunspecClient.readMeterData(this.unitId);
|
|
1322
|
+
// SDK readers swallow modbus errors and return null; detect that here so the
|
|
1323
|
+
// appliance is marked offline and the retry manager starts its backoff.
|
|
1324
|
+
if (await this.markOfflineIfUnhealthy()) {
|
|
1325
|
+
return messages;
|
|
1326
|
+
}
|
|
1294
1327
|
if (meterData) {
|
|
1295
1328
|
const meterMessage = {
|
|
1296
1329
|
id: (0, node_crypto_1.randomUUID)(),
|
|
@@ -60,6 +60,16 @@ export declare abstract class BaseSunspecDevice {
|
|
|
60
60
|
* per failed attempt with the running consecutive-failure count.
|
|
61
61
|
*/
|
|
62
62
|
protected onConnectionFailure(_consecutiveFailures: number): Promise<void>;
|
|
63
|
+
/**
|
|
64
|
+
* Detect a silent read failure: the SDK's per-model readers swallow modbus errors and
|
|
65
|
+
* return null/[] rather than throwing, so a readData() cycle can complete "successfully"
|
|
66
|
+
* with no usable data while the underlying connection is broken. Once
|
|
67
|
+
* `connectionHealth.isHealthy()` flips to false, treat the device as offline so the
|
|
68
|
+
* appliance state is updated and the retry manager starts the backoff schedule.
|
|
69
|
+
*
|
|
70
|
+
* Returns true if the device was marked offline.
|
|
71
|
+
*/
|
|
72
|
+
protected markOfflineIfUnhealthy(): Promise<boolean>;
|
|
63
73
|
/**
|
|
64
74
|
* Mark the device as offline: close the underlying socket so the next readData()
|
|
65
75
|
* cycle sees isConnected() === false and tryReconnect() can establish a fresh
|
|
@@ -9,7 +9,8 @@ exports.DEFAULT_RETRY_CONFIG = {
|
|
|
9
9
|
{ intervalMs: 10_000, durationMs: 60_000 }, // Phase 1: every 10s for 1 minute
|
|
10
10
|
{ intervalMs: 30_000, durationMs: 120_000 }, // Phase 2: every 30s for 2 minutes
|
|
11
11
|
{ intervalMs: 60_000, durationMs: 300_000 }, // Phase 3: every 1m for 5 minutes
|
|
12
|
-
{ intervalMs: 300_000, durationMs:
|
|
12
|
+
{ intervalMs: 300_000, durationMs: 600_000 }, // Phase 4: every 5m for 10 minutes
|
|
13
|
+
{ intervalMs: 900_000, durationMs: 0 }, // Phase 5: every 15m forever
|
|
13
14
|
]
|
|
14
15
|
};
|
|
15
16
|
/**
|
|
@@ -699,23 +699,64 @@ class SunspecModbusClient {
|
|
|
699
699
|
throw error;
|
|
700
700
|
}
|
|
701
701
|
}
|
|
702
|
+
/**
|
|
703
|
+
* SunSpec inverter + meter models come in two encoding families: int+SF (101/103 inverter,
|
|
704
|
+
* 201/203/204 meter) and float (111/112/113 inverter, 211/212/213/214 meter). Some devices
|
|
705
|
+
* advertise BOTH for compatibility. We must pick one family and use it consistently across
|
|
706
|
+
* inverter and meter on the same unit — otherwise we end up decoding the int+SF inverter
|
|
707
|
+
* while reading the float meter (or vice-versa), which is confusing and risks subtle bugs.
|
|
708
|
+
*
|
|
709
|
+
* Rule: count how many models each family contributes to the discovered directory for this
|
|
710
|
+
* unit; use the family with the higher count. On a tie, prefer int+SF (the SDK's historical
|
|
711
|
+
* default and the more common encoding in the wild).
|
|
712
|
+
*/
|
|
713
|
+
getPreferredEncoding(unitId) {
|
|
714
|
+
const INT_SF_IDS = [101, 103, 201, 203, 204];
|
|
715
|
+
const FLOAT_IDS = [111, 112, 113, 211, 212, 213, 214];
|
|
716
|
+
const models = this.discoveredModelsByUnit.get(unitId);
|
|
717
|
+
if (!models)
|
|
718
|
+
return 'int_sf';
|
|
719
|
+
let intSfCount = 0;
|
|
720
|
+
let floatCount = 0;
|
|
721
|
+
for (const id of INT_SF_IDS)
|
|
722
|
+
if (models.has(id))
|
|
723
|
+
intSfCount++;
|
|
724
|
+
for (const id of FLOAT_IDS)
|
|
725
|
+
if (models.has(id))
|
|
726
|
+
floatCount++;
|
|
727
|
+
const preferred = floatCount > intSfCount ? 'float' : 'int_sf';
|
|
728
|
+
if (intSfCount > 0 && floatCount > 0) {
|
|
729
|
+
console.debug(`Unit ${unitId} advertises both int+SF (${intSfCount}) and float (${floatCount}) models — ` +
|
|
730
|
+
`using ${preferred} consistently across inverter+meter.`);
|
|
731
|
+
}
|
|
732
|
+
return preferred;
|
|
733
|
+
}
|
|
702
734
|
/**
|
|
703
735
|
* Read inverter data. Detects which SunSpec inverter model the device exposes —
|
|
704
736
|
* int+SF (101/103) or float (111/112/113) — by checking the discovered model directory,
|
|
705
737
|
* and dispatches to the appropriate decoder.
|
|
738
|
+
*
|
|
739
|
+
* When a device advertises BOTH encodings, picks the family with more discovered models
|
|
740
|
+
* for this unit (see getPreferredEncoding). Never reads both.
|
|
706
741
|
*/
|
|
707
742
|
async readInverterData(unitId) {
|
|
708
|
-
const
|
|
743
|
+
const intSfOrder = [
|
|
709
744
|
{ id: 103, reader: m => this.readThreePhaseInverterData_IntSF(unitId, m) },
|
|
745
|
+
{ id: 101, reader: m => this.readSinglePhaseInverterData(unitId, m) },
|
|
746
|
+
];
|
|
747
|
+
const floatOrder = [
|
|
710
748
|
{ id: 113, reader: m => this.readFloatInverterData(unitId, m, 113) },
|
|
711
749
|
{ id: 112, reader: m => this.readFloatInverterData(unitId, m, 112) },
|
|
712
|
-
{ id: 101, reader: m => this.readSinglePhaseInverterData(unitId, m) },
|
|
713
750
|
{ id: 111, reader: m => this.readFloatInverterData(unitId, m, 111) },
|
|
714
751
|
];
|
|
752
|
+
const preferred = this.getPreferredEncoding(unitId);
|
|
753
|
+
const tryOrder = preferred === 'float'
|
|
754
|
+
? [...floatOrder, ...intSfOrder] // fall through to int+SF only if no float model exists
|
|
755
|
+
: [...intSfOrder, ...floatOrder];
|
|
715
756
|
for (const { id, reader } of tryOrder) {
|
|
716
757
|
const model = this.findModel(unitId, id);
|
|
717
758
|
if (model) {
|
|
718
|
-
console.debug(`Using inverter Model ${id} at address ${model.address} (length ${model.length}) on unit ${unitId}`);
|
|
759
|
+
console.debug(`Using inverter Model ${id} at address ${model.address} (length ${model.length}) on unit ${unitId} (preferred encoding: ${preferred})`);
|
|
719
760
|
return reader(model);
|
|
720
761
|
}
|
|
721
762
|
}
|
|
@@ -1753,27 +1794,46 @@ class SunspecModbusClient {
|
|
|
1753
1794
|
* Read meter data. Detects which SunSpec meter model the device exposes —
|
|
1754
1795
|
* int+SF (201/203/204) or float (211/212/213/214) — by checking the discovered
|
|
1755
1796
|
* model directory, and dispatches to the appropriate decoder.
|
|
1797
|
+
*
|
|
1798
|
+
* When a device advertises BOTH encodings, picks the family with more discovered models
|
|
1799
|
+
* for this unit (see getPreferredEncoding). Never reads both.
|
|
1756
1800
|
*/
|
|
1757
1801
|
async readMeterData(unitId) {
|
|
1758
|
-
const
|
|
1759
|
-
|
|
1760
|
-
const
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1802
|
+
const preferred = this.getPreferredEncoding(unitId);
|
|
1803
|
+
const tryFloat = async () => {
|
|
1804
|
+
const floatIds = [213, 214, 212, 211];
|
|
1805
|
+
for (const id of floatIds) {
|
|
1806
|
+
const model = this.findModel(unitId, id);
|
|
1807
|
+
if (model) {
|
|
1808
|
+
console.debug(`Using meter Model ${id} at address ${model.address} (length ${model.length}) on unit ${unitId} (preferred encoding: ${preferred})`);
|
|
1809
|
+
return this.readFloatMeterData(unitId, model, id);
|
|
1810
|
+
}
|
|
1764
1811
|
}
|
|
1765
|
-
}
|
|
1766
|
-
let model = this.findModel(unitId, sunspec_interfaces_js_1.SunspecModelId.Meter3Phase);
|
|
1767
|
-
if (!model) {
|
|
1768
|
-
model = this.findModel(unitId, sunspec_interfaces_js_1.SunspecModelId.MeterWye);
|
|
1769
|
-
}
|
|
1770
|
-
if (!model) {
|
|
1771
|
-
model = this.findModel(unitId, sunspec_interfaces_js_1.SunspecModelId.MeterSinglePhase);
|
|
1772
|
-
}
|
|
1773
|
-
if (!model) {
|
|
1774
|
-
console.debug(`No meter model found on unit ${unitId}`);
|
|
1775
1812
|
return null;
|
|
1776
|
-
}
|
|
1813
|
+
};
|
|
1814
|
+
const tryIntSf = async () => {
|
|
1815
|
+
let model = this.findModel(unitId, sunspec_interfaces_js_1.SunspecModelId.Meter3Phase)
|
|
1816
|
+
?? this.findModel(unitId, sunspec_interfaces_js_1.SunspecModelId.MeterWye)
|
|
1817
|
+
?? this.findModel(unitId, sunspec_interfaces_js_1.SunspecModelId.MeterSinglePhase);
|
|
1818
|
+
if (!model)
|
|
1819
|
+
return null;
|
|
1820
|
+
console.debug(`Using meter Model ${model.id} at address ${model.address} (length ${model.length}) on unit ${unitId} (preferred encoding: ${preferred})`);
|
|
1821
|
+
return this.readIntSfMeterData(unitId, model);
|
|
1822
|
+
};
|
|
1823
|
+
const primary = preferred === 'float' ? tryFloat : tryIntSf;
|
|
1824
|
+
const fallback = preferred === 'float' ? tryIntSf : tryFloat;
|
|
1825
|
+
const primaryResult = await primary();
|
|
1826
|
+
if (primaryResult)
|
|
1827
|
+
return primaryResult;
|
|
1828
|
+
// Fall back to the other family only when the preferred family has no model at all.
|
|
1829
|
+
return fallback();
|
|
1830
|
+
}
|
|
1831
|
+
/**
|
|
1832
|
+
* Read meter data from an int+SF variant model (201/203/204). Extracted from the original
|
|
1833
|
+
* readMeterData body so the preferred-encoding dispatcher above can branch cleanly without
|
|
1834
|
+
* duplicating ~60 lines of register decoding.
|
|
1835
|
+
*/
|
|
1836
|
+
async readIntSfMeterData(unitId, model) {
|
|
1777
1837
|
console.debug(`Reading Meter Data from Model ${model.id} at base address: ${model.address} (unit ${unitId})`);
|
|
1778
1838
|
try {
|
|
1779
1839
|
// Different meter models have different register offsets
|
|
@@ -201,10 +201,25 @@ export declare class SunspecModbusClient {
|
|
|
201
201
|
* Helper to write register value(s)
|
|
202
202
|
*/
|
|
203
203
|
writeRegisterValue(unitId: number, address: number, value: number | number[], dataType?: EnergyAppModbusDataType): Promise<boolean>;
|
|
204
|
+
/**
|
|
205
|
+
* SunSpec inverter + meter models come in two encoding families: int+SF (101/103 inverter,
|
|
206
|
+
* 201/203/204 meter) and float (111/112/113 inverter, 211/212/213/214 meter). Some devices
|
|
207
|
+
* advertise BOTH for compatibility. We must pick one family and use it consistently across
|
|
208
|
+
* inverter and meter on the same unit — otherwise we end up decoding the int+SF inverter
|
|
209
|
+
* while reading the float meter (or vice-versa), which is confusing and risks subtle bugs.
|
|
210
|
+
*
|
|
211
|
+
* Rule: count how many models each family contributes to the discovered directory for this
|
|
212
|
+
* unit; use the family with the higher count. On a tie, prefer int+SF (the SDK's historical
|
|
213
|
+
* default and the more common encoding in the wild).
|
|
214
|
+
*/
|
|
215
|
+
private getPreferredEncoding;
|
|
204
216
|
/**
|
|
205
217
|
* Read inverter data. Detects which SunSpec inverter model the device exposes —
|
|
206
218
|
* int+SF (101/103) or float (111/112/113) — by checking the discovered model directory,
|
|
207
219
|
* and dispatches to the appropriate decoder.
|
|
220
|
+
*
|
|
221
|
+
* When a device advertises BOTH encodings, picks the family with more discovered models
|
|
222
|
+
* for this unit (see getPreferredEncoding). Never reads both.
|
|
208
223
|
*/
|
|
209
224
|
readInverterData(unitId: number): Promise<SunspecInverterData | null>;
|
|
210
225
|
/**
|
|
@@ -318,8 +333,17 @@ export declare class SunspecModbusClient {
|
|
|
318
333
|
* Read meter data. Detects which SunSpec meter model the device exposes —
|
|
319
334
|
* int+SF (201/203/204) or float (211/212/213/214) — by checking the discovered
|
|
320
335
|
* model directory, and dispatches to the appropriate decoder.
|
|
336
|
+
*
|
|
337
|
+
* When a device advertises BOTH encodings, picks the family with more discovered models
|
|
338
|
+
* for this unit (see getPreferredEncoding). Never reads both.
|
|
321
339
|
*/
|
|
322
340
|
readMeterData(unitId: number): Promise<SunspecMeterData | null>;
|
|
341
|
+
/**
|
|
342
|
+
* Read meter data from an int+SF variant model (201/203/204). Extracted from the original
|
|
343
|
+
* readMeterData body so the preferred-encoding dispatcher above can branch cleanly without
|
|
344
|
+
* duplicating ~60 lines of register decoding.
|
|
345
|
+
*/
|
|
346
|
+
private readIntSfMeterData;
|
|
323
347
|
/**
|
|
324
348
|
* Read meter data from a float-variant model: 211 (single-phase), 212 (split-phase),
|
|
325
349
|
* 213 (3-phase Wye), or 214 (3-phase Delta).
|
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.63';
|
|
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
|
@@ -5,11 +5,12 @@
|
|
|
5
5
|
* Called on each readData() cycle; determines whether enough time has elapsed
|
|
6
6
|
* to attempt a reconnection based on the current retry phase.
|
|
7
7
|
*
|
|
8
|
-
* Default schedule:
|
|
8
|
+
* Default schedule (infinite retry, max 15m backoff):
|
|
9
9
|
* - Phase 1: every 10s for 1 minute
|
|
10
10
|
* - Phase 2: every 30s for 2 minutes
|
|
11
11
|
* - Phase 3: every 1m for 5 minutes
|
|
12
|
-
* - Phase 4: every 5m
|
|
12
|
+
* - Phase 4: every 5m for 10 minutes
|
|
13
|
+
* - Phase 5: every 15m forever
|
|
13
14
|
*/
|
|
14
15
|
import { IRetryConfig, IRetryPhase } from './sunspec-interfaces.js';
|
|
15
16
|
export declare class ConnectionRetryManager {
|
|
@@ -5,11 +5,12 @@
|
|
|
5
5
|
* Called on each readData() cycle; determines whether enough time has elapsed
|
|
6
6
|
* to attempt a reconnection based on the current retry phase.
|
|
7
7
|
*
|
|
8
|
-
* Default schedule:
|
|
8
|
+
* Default schedule (infinite retry, max 15m backoff):
|
|
9
9
|
* - Phase 1: every 10s for 1 minute
|
|
10
10
|
* - Phase 2: every 30s for 2 minutes
|
|
11
11
|
* - Phase 3: every 1m for 5 minutes
|
|
12
|
-
* - Phase 4: every 5m
|
|
12
|
+
* - Phase 4: every 5m for 10 minutes
|
|
13
|
+
* - Phase 5: every 15m forever
|
|
13
14
|
*/
|
|
14
15
|
import { DEFAULT_RETRY_CONFIG } from './sunspec-interfaces.js';
|
|
15
16
|
export class ConnectionRetryManager {
|
|
@@ -60,6 +60,16 @@ export declare abstract class BaseSunspecDevice {
|
|
|
60
60
|
* per failed attempt with the running consecutive-failure count.
|
|
61
61
|
*/
|
|
62
62
|
protected onConnectionFailure(_consecutiveFailures: number): Promise<void>;
|
|
63
|
+
/**
|
|
64
|
+
* Detect a silent read failure: the SDK's per-model readers swallow modbus errors and
|
|
65
|
+
* return null/[] rather than throwing, so a readData() cycle can complete "successfully"
|
|
66
|
+
* with no usable data while the underlying connection is broken. Once
|
|
67
|
+
* `connectionHealth.isHealthy()` flips to false, treat the device as offline so the
|
|
68
|
+
* appliance state is updated and the retry manager starts the backoff schedule.
|
|
69
|
+
*
|
|
70
|
+
* Returns true if the device was marked offline.
|
|
71
|
+
*/
|
|
72
|
+
protected markOfflineIfUnhealthy(): Promise<boolean>;
|
|
63
73
|
/**
|
|
64
74
|
* Mark the device as offline: close the underlying socket so the next readData()
|
|
65
75
|
* cycle sees isConnected() === false and tryReconnect() can establish a fresh
|
package/dist/sunspec-devices.js
CHANGED
|
@@ -184,6 +184,24 @@ export class BaseSunspecDevice {
|
|
|
184
184
|
async onConnectionFailure(_consecutiveFailures) {
|
|
185
185
|
// Default: no-op. SunspecInverter overrides to publish a faulted status.
|
|
186
186
|
}
|
|
187
|
+
/**
|
|
188
|
+
* Detect a silent read failure: the SDK's per-model readers swallow modbus errors and
|
|
189
|
+
* return null/[] rather than throwing, so a readData() cycle can complete "successfully"
|
|
190
|
+
* with no usable data while the underlying connection is broken. Once
|
|
191
|
+
* `connectionHealth.isHealthy()` flips to false, treat the device as offline so the
|
|
192
|
+
* appliance state is updated and the retry manager starts the backoff schedule.
|
|
193
|
+
*
|
|
194
|
+
* Returns true if the device was marked offline.
|
|
195
|
+
*/
|
|
196
|
+
async markOfflineIfUnhealthy() {
|
|
197
|
+
if (this.sunspecClient.isHealthy(this.unitId)) {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
console.warn(`${this.constructor.name} ${this.applianceId}: connection unhealthy after read cycle ` +
|
|
201
|
+
`(consecutiveFailures=${this.sunspecClient.getConnectionHealth().getConsecutiveFailures()}) — marking offline`);
|
|
202
|
+
await this.markOffline();
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
187
205
|
/**
|
|
188
206
|
* Mark the device as offline: close the underlying socket so the next readData()
|
|
189
207
|
* cycle sees isConnected() === false and tryReconnect() can establish a fresh
|
|
@@ -334,6 +352,11 @@ export class SunspecInverter extends BaseSunspecDevice {
|
|
|
334
352
|
const mpptDataList = await this.sunspecClient.readAllMPPTData(this.unitId);
|
|
335
353
|
const inverterSettings = await this.sunspecClient.readInverterSettings(this.unitId);
|
|
336
354
|
const dcStrings = this.mapMPPTToStrings(mpptDataList);
|
|
355
|
+
// SDK readers swallow modbus errors and return null/[]; detect that here so the
|
|
356
|
+
// appliance is marked offline and the retry manager starts its backoff.
|
|
357
|
+
if (await this.markOfflineIfUnhealthy()) {
|
|
358
|
+
return messages;
|
|
359
|
+
}
|
|
337
360
|
if (inverterData) {
|
|
338
361
|
const pvPowerW = dcStrings.reduce((sum, s) => sum + (s.powerW || 0), 0);
|
|
339
362
|
console.debug(`Got PV Power from DC strings: ${pvPowerW}W`);
|
|
@@ -801,6 +824,11 @@ export class SunspecBattery extends BaseSunspecDevice {
|
|
|
801
824
|
const batteryData = await this.sunspecClient.readBatteryData(this.unitId);
|
|
802
825
|
const mpptDataList = await this.sunspecClient.readAllMPPTData(this.unitId);
|
|
803
826
|
const mpptBatteryPowerW = this.extractBatteryPowerFromMPPT(mpptDataList);
|
|
827
|
+
// SDK readers swallow modbus errors and return null/[]; detect that here so the
|
|
828
|
+
// appliance is marked offline and the retry manager starts its backoff.
|
|
829
|
+
if (await this.markOfflineIfUnhealthy()) {
|
|
830
|
+
return messages;
|
|
831
|
+
}
|
|
804
832
|
if (batteryData) {
|
|
805
833
|
const advancedBatteryModel = this.sunspecClient.findModel(this.unitId, 801);
|
|
806
834
|
const batteryBaseModel = this.sunspecClient.findModel(this.unitId, SunspecModelId.BatteryBase);
|
|
@@ -1285,6 +1313,11 @@ export class SunspecMeter extends BaseSunspecDevice {
|
|
|
1285
1313
|
try {
|
|
1286
1314
|
// Read meter data
|
|
1287
1315
|
const meterData = await this.sunspecClient.readMeterData(this.unitId);
|
|
1316
|
+
// SDK readers swallow modbus errors and return null; detect that here so the
|
|
1317
|
+
// appliance is marked offline and the retry manager starts its backoff.
|
|
1318
|
+
if (await this.markOfflineIfUnhealthy()) {
|
|
1319
|
+
return messages;
|
|
1320
|
+
}
|
|
1288
1321
|
if (meterData) {
|
|
1289
1322
|
const meterMessage = {
|
|
1290
1323
|
id: randomUUID(),
|
|
@@ -6,7 +6,8 @@ export const DEFAULT_RETRY_CONFIG = {
|
|
|
6
6
|
{ intervalMs: 10_000, durationMs: 60_000 }, // Phase 1: every 10s for 1 minute
|
|
7
7
|
{ intervalMs: 30_000, durationMs: 120_000 }, // Phase 2: every 30s for 2 minutes
|
|
8
8
|
{ intervalMs: 60_000, durationMs: 300_000 }, // Phase 3: every 1m for 5 minutes
|
|
9
|
-
{ intervalMs: 300_000, durationMs:
|
|
9
|
+
{ intervalMs: 300_000, durationMs: 600_000 }, // Phase 4: every 5m for 10 minutes
|
|
10
|
+
{ intervalMs: 900_000, durationMs: 0 }, // Phase 5: every 15m forever
|
|
10
11
|
]
|
|
11
12
|
};
|
|
12
13
|
/**
|
|
@@ -201,10 +201,25 @@ export declare class SunspecModbusClient {
|
|
|
201
201
|
* Helper to write register value(s)
|
|
202
202
|
*/
|
|
203
203
|
writeRegisterValue(unitId: number, address: number, value: number | number[], dataType?: EnergyAppModbusDataType): Promise<boolean>;
|
|
204
|
+
/**
|
|
205
|
+
* SunSpec inverter + meter models come in two encoding families: int+SF (101/103 inverter,
|
|
206
|
+
* 201/203/204 meter) and float (111/112/113 inverter, 211/212/213/214 meter). Some devices
|
|
207
|
+
* advertise BOTH for compatibility. We must pick one family and use it consistently across
|
|
208
|
+
* inverter and meter on the same unit — otherwise we end up decoding the int+SF inverter
|
|
209
|
+
* while reading the float meter (or vice-versa), which is confusing and risks subtle bugs.
|
|
210
|
+
*
|
|
211
|
+
* Rule: count how many models each family contributes to the discovered directory for this
|
|
212
|
+
* unit; use the family with the higher count. On a tie, prefer int+SF (the SDK's historical
|
|
213
|
+
* default and the more common encoding in the wild).
|
|
214
|
+
*/
|
|
215
|
+
private getPreferredEncoding;
|
|
204
216
|
/**
|
|
205
217
|
* Read inverter data. Detects which SunSpec inverter model the device exposes —
|
|
206
218
|
* int+SF (101/103) or float (111/112/113) — by checking the discovered model directory,
|
|
207
219
|
* and dispatches to the appropriate decoder.
|
|
220
|
+
*
|
|
221
|
+
* When a device advertises BOTH encodings, picks the family with more discovered models
|
|
222
|
+
* for this unit (see getPreferredEncoding). Never reads both.
|
|
208
223
|
*/
|
|
209
224
|
readInverterData(unitId: number): Promise<SunspecInverterData | null>;
|
|
210
225
|
/**
|
|
@@ -318,8 +333,17 @@ export declare class SunspecModbusClient {
|
|
|
318
333
|
* Read meter data. Detects which SunSpec meter model the device exposes —
|
|
319
334
|
* int+SF (201/203/204) or float (211/212/213/214) — by checking the discovered
|
|
320
335
|
* model directory, and dispatches to the appropriate decoder.
|
|
336
|
+
*
|
|
337
|
+
* When a device advertises BOTH encodings, picks the family with more discovered models
|
|
338
|
+
* for this unit (see getPreferredEncoding). Never reads both.
|
|
321
339
|
*/
|
|
322
340
|
readMeterData(unitId: number): Promise<SunspecMeterData | null>;
|
|
341
|
+
/**
|
|
342
|
+
* Read meter data from an int+SF variant model (201/203/204). Extracted from the original
|
|
343
|
+
* readMeterData body so the preferred-encoding dispatcher above can branch cleanly without
|
|
344
|
+
* duplicating ~60 lines of register decoding.
|
|
345
|
+
*/
|
|
346
|
+
private readIntSfMeterData;
|
|
323
347
|
/**
|
|
324
348
|
* Read meter data from a float-variant model: 211 (single-phase), 212 (split-phase),
|
|
325
349
|
* 213 (3-phase Wye), or 214 (3-phase Delta).
|
|
@@ -694,23 +694,64 @@ export class SunspecModbusClient {
|
|
|
694
694
|
throw error;
|
|
695
695
|
}
|
|
696
696
|
}
|
|
697
|
+
/**
|
|
698
|
+
* SunSpec inverter + meter models come in two encoding families: int+SF (101/103 inverter,
|
|
699
|
+
* 201/203/204 meter) and float (111/112/113 inverter, 211/212/213/214 meter). Some devices
|
|
700
|
+
* advertise BOTH for compatibility. We must pick one family and use it consistently across
|
|
701
|
+
* inverter and meter on the same unit — otherwise we end up decoding the int+SF inverter
|
|
702
|
+
* while reading the float meter (or vice-versa), which is confusing and risks subtle bugs.
|
|
703
|
+
*
|
|
704
|
+
* Rule: count how many models each family contributes to the discovered directory for this
|
|
705
|
+
* unit; use the family with the higher count. On a tie, prefer int+SF (the SDK's historical
|
|
706
|
+
* default and the more common encoding in the wild).
|
|
707
|
+
*/
|
|
708
|
+
getPreferredEncoding(unitId) {
|
|
709
|
+
const INT_SF_IDS = [101, 103, 201, 203, 204];
|
|
710
|
+
const FLOAT_IDS = [111, 112, 113, 211, 212, 213, 214];
|
|
711
|
+
const models = this.discoveredModelsByUnit.get(unitId);
|
|
712
|
+
if (!models)
|
|
713
|
+
return 'int_sf';
|
|
714
|
+
let intSfCount = 0;
|
|
715
|
+
let floatCount = 0;
|
|
716
|
+
for (const id of INT_SF_IDS)
|
|
717
|
+
if (models.has(id))
|
|
718
|
+
intSfCount++;
|
|
719
|
+
for (const id of FLOAT_IDS)
|
|
720
|
+
if (models.has(id))
|
|
721
|
+
floatCount++;
|
|
722
|
+
const preferred = floatCount > intSfCount ? 'float' : 'int_sf';
|
|
723
|
+
if (intSfCount > 0 && floatCount > 0) {
|
|
724
|
+
console.debug(`Unit ${unitId} advertises both int+SF (${intSfCount}) and float (${floatCount}) models — ` +
|
|
725
|
+
`using ${preferred} consistently across inverter+meter.`);
|
|
726
|
+
}
|
|
727
|
+
return preferred;
|
|
728
|
+
}
|
|
697
729
|
/**
|
|
698
730
|
* Read inverter data. Detects which SunSpec inverter model the device exposes —
|
|
699
731
|
* int+SF (101/103) or float (111/112/113) — by checking the discovered model directory,
|
|
700
732
|
* and dispatches to the appropriate decoder.
|
|
733
|
+
*
|
|
734
|
+
* When a device advertises BOTH encodings, picks the family with more discovered models
|
|
735
|
+
* for this unit (see getPreferredEncoding). Never reads both.
|
|
701
736
|
*/
|
|
702
737
|
async readInverterData(unitId) {
|
|
703
|
-
const
|
|
738
|
+
const intSfOrder = [
|
|
704
739
|
{ id: 103, reader: m => this.readThreePhaseInverterData_IntSF(unitId, m) },
|
|
740
|
+
{ id: 101, reader: m => this.readSinglePhaseInverterData(unitId, m) },
|
|
741
|
+
];
|
|
742
|
+
const floatOrder = [
|
|
705
743
|
{ id: 113, reader: m => this.readFloatInverterData(unitId, m, 113) },
|
|
706
744
|
{ id: 112, reader: m => this.readFloatInverterData(unitId, m, 112) },
|
|
707
|
-
{ id: 101, reader: m => this.readSinglePhaseInverterData(unitId, m) },
|
|
708
745
|
{ id: 111, reader: m => this.readFloatInverterData(unitId, m, 111) },
|
|
709
746
|
];
|
|
747
|
+
const preferred = this.getPreferredEncoding(unitId);
|
|
748
|
+
const tryOrder = preferred === 'float'
|
|
749
|
+
? [...floatOrder, ...intSfOrder] // fall through to int+SF only if no float model exists
|
|
750
|
+
: [...intSfOrder, ...floatOrder];
|
|
710
751
|
for (const { id, reader } of tryOrder) {
|
|
711
752
|
const model = this.findModel(unitId, id);
|
|
712
753
|
if (model) {
|
|
713
|
-
console.debug(`Using inverter Model ${id} at address ${model.address} (length ${model.length}) on unit ${unitId}`);
|
|
754
|
+
console.debug(`Using inverter Model ${id} at address ${model.address} (length ${model.length}) on unit ${unitId} (preferred encoding: ${preferred})`);
|
|
714
755
|
return reader(model);
|
|
715
756
|
}
|
|
716
757
|
}
|
|
@@ -1748,27 +1789,46 @@ export class SunspecModbusClient {
|
|
|
1748
1789
|
* Read meter data. Detects which SunSpec meter model the device exposes —
|
|
1749
1790
|
* int+SF (201/203/204) or float (211/212/213/214) — by checking the discovered
|
|
1750
1791
|
* model directory, and dispatches to the appropriate decoder.
|
|
1792
|
+
*
|
|
1793
|
+
* When a device advertises BOTH encodings, picks the family with more discovered models
|
|
1794
|
+
* for this unit (see getPreferredEncoding). Never reads both.
|
|
1751
1795
|
*/
|
|
1752
1796
|
async readMeterData(unitId) {
|
|
1753
|
-
const
|
|
1754
|
-
|
|
1755
|
-
const
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1797
|
+
const preferred = this.getPreferredEncoding(unitId);
|
|
1798
|
+
const tryFloat = async () => {
|
|
1799
|
+
const floatIds = [213, 214, 212, 211];
|
|
1800
|
+
for (const id of floatIds) {
|
|
1801
|
+
const model = this.findModel(unitId, id);
|
|
1802
|
+
if (model) {
|
|
1803
|
+
console.debug(`Using meter Model ${id} at address ${model.address} (length ${model.length}) on unit ${unitId} (preferred encoding: ${preferred})`);
|
|
1804
|
+
return this.readFloatMeterData(unitId, model, id);
|
|
1805
|
+
}
|
|
1759
1806
|
}
|
|
1760
|
-
}
|
|
1761
|
-
let model = this.findModel(unitId, SunspecModelId.Meter3Phase);
|
|
1762
|
-
if (!model) {
|
|
1763
|
-
model = this.findModel(unitId, SunspecModelId.MeterWye);
|
|
1764
|
-
}
|
|
1765
|
-
if (!model) {
|
|
1766
|
-
model = this.findModel(unitId, SunspecModelId.MeterSinglePhase);
|
|
1767
|
-
}
|
|
1768
|
-
if (!model) {
|
|
1769
|
-
console.debug(`No meter model found on unit ${unitId}`);
|
|
1770
1807
|
return null;
|
|
1771
|
-
}
|
|
1808
|
+
};
|
|
1809
|
+
const tryIntSf = async () => {
|
|
1810
|
+
let model = this.findModel(unitId, SunspecModelId.Meter3Phase)
|
|
1811
|
+
?? this.findModel(unitId, SunspecModelId.MeterWye)
|
|
1812
|
+
?? this.findModel(unitId, SunspecModelId.MeterSinglePhase);
|
|
1813
|
+
if (!model)
|
|
1814
|
+
return null;
|
|
1815
|
+
console.debug(`Using meter Model ${model.id} at address ${model.address} (length ${model.length}) on unit ${unitId} (preferred encoding: ${preferred})`);
|
|
1816
|
+
return this.readIntSfMeterData(unitId, model);
|
|
1817
|
+
};
|
|
1818
|
+
const primary = preferred === 'float' ? tryFloat : tryIntSf;
|
|
1819
|
+
const fallback = preferred === 'float' ? tryIntSf : tryFloat;
|
|
1820
|
+
const primaryResult = await primary();
|
|
1821
|
+
if (primaryResult)
|
|
1822
|
+
return primaryResult;
|
|
1823
|
+
// Fall back to the other family only when the preferred family has no model at all.
|
|
1824
|
+
return fallback();
|
|
1825
|
+
}
|
|
1826
|
+
/**
|
|
1827
|
+
* Read meter data from an int+SF variant model (201/203/204). Extracted from the original
|
|
1828
|
+
* readMeterData body so the preferred-encoding dispatcher above can branch cleanly without
|
|
1829
|
+
* duplicating ~60 lines of register decoding.
|
|
1830
|
+
*/
|
|
1831
|
+
async readIntSfMeterData(unitId, model) {
|
|
1772
1832
|
console.debug(`Reading Meter Data from Model ${model.id} at base address: ${model.address} (unit ${unitId})`);
|
|
1773
1833
|
try {
|
|
1774
1834
|
// Different meter models have different register offsets
|
package/dist/version.d.ts
CHANGED
package/dist/version.js
CHANGED