@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.
@@ -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 forever
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 forever
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: 0 }, // Phase 4: every 5m forever
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 tryOrder = [
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 floatIds = [213, 214, 212, 211];
1759
- for (const id of floatIds) {
1760
- const model = this.findModel(unitId, id);
1761
- if (model) {
1762
- console.debug(`Using meter Model ${id} at address ${model.address} (length ${model.length}) on unit ${unitId}`);
1763
- return this.readFloatMeterData(unitId, model, id);
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).
@@ -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.61';
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
@@ -5,7 +5,7 @@
5
5
  /**
6
6
  * Current version of the enyo Energy App SDK.
7
7
  */
8
- export declare const SDK_VERSION = "0.0.61";
8
+ export declare const SDK_VERSION = "0.0.63";
9
9
  /**
10
10
  * Gets the current SDK version.
11
11
  * @returns The semantic version string of the SDK
@@ -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 forever
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 forever
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
@@ -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: 0 }, // Phase 4: every 5m forever
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 tryOrder = [
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 floatIds = [213, 214, 212, 211];
1754
- for (const id of floatIds) {
1755
- const model = this.findModel(unitId, id);
1756
- if (model) {
1757
- console.debug(`Using meter Model ${id} at address ${model.address} (length ${model.length}) on unit ${unitId}`);
1758
- return this.readFloatMeterData(unitId, model, id);
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
@@ -5,7 +5,7 @@
5
5
  /**
6
6
  * Current version of the enyo Energy App SDK.
7
7
  */
8
- export declare const SDK_VERSION = "0.0.61";
8
+ export declare const SDK_VERSION = "0.0.63";
9
9
  /**
10
10
  * Gets the current SDK version.
11
11
  * @returns The semantic version string of the SDK
package/dist/version.js CHANGED
@@ -5,7 +5,7 @@
5
5
  /**
6
6
  * Current version of the enyo Energy App SDK.
7
7
  */
8
- export const SDK_VERSION = '0.0.61';
8
+ export const SDK_VERSION = '0.0.63';
9
9
  /**
10
10
  * Gets the current SDK version.
11
11
  * @returns The semantic version string of the SDK
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enyo-energy/sunspec-sdk",
3
- "version": "0.0.61",
3
+ "version": "0.0.63",
4
4
  "description": "enyo Energy Sunspec SDK",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",