@enyo-energy/sunspec-sdk 0.0.83 → 0.0.85

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.
@@ -73,22 +73,30 @@ class SunspecBatteryScheduleHandler extends storage_schedule_handler_js_1.Storag
73
73
  // baseline so the persisted snapshot continues to point at the
74
74
  // original pre-schedule state. The library will overwrite its
75
75
  // storage row with the same value — harmless.
76
- this.installedWChaMax = this.originalBaseline.wChaMax;
76
+ const wChaMax = this.originalBaseline.wChaMax;
77
+ if (!wChaMax || wChaMax <= 0) {
78
+ throw new Error(`SunspecBatteryScheduleHandler ${this.applianceIdForLog}: cannot start schedule — originalBaseline has no usable wChaMax (got ${wChaMax})`);
79
+ }
80
+ this.installedWChaMax = wChaMax;
77
81
  return { ...this.originalBaseline };
78
82
  }
79
83
  const controls = await this.sunspecClient.readBatteryControls(this.unitId);
80
84
  if (!controls) {
81
85
  throw new Error(`SunspecBatteryScheduleHandler ${this.applianceIdForLog}: failed to read pre-schedule controls`);
82
86
  }
87
+ const wChaMax = controls.wChaMax;
88
+ if (!wChaMax || wChaMax <= 0) {
89
+ throw new Error(`SunspecBatteryScheduleHandler ${this.applianceIdForLog}: cannot start schedule — device did not report a usable wChaMax (got ${wChaMax})`);
90
+ }
83
91
  const baseline = {
84
92
  storCtlMod: controls.storCtlMod,
85
93
  chaGriSet: controls.chaGriSet,
86
- wChaMax: controls.wChaMax,
94
+ wChaMax,
87
95
  inWRte: controls.inWRte,
88
96
  outWRte: controls.outWRte,
89
97
  };
90
98
  this.originalBaseline = baseline;
91
- this.installedWChaMax = baseline.wChaMax;
99
+ this.installedWChaMax = wChaMax;
92
100
  return { ...baseline };
93
101
  }
94
102
  onChange(active, _previous) {
@@ -343,7 +343,16 @@ class SunspecInverter extends BaseSunspecDevice {
343
343
  capabilities;
344
344
  /** Emit a connection-lost faulted status after this many consecutive failed reconnect attempts. */
345
345
  static CONNECTION_FAULT_THRESHOLD = 3;
346
+ /**
347
+ * Require this many consecutive operatingState=7 (FAULT) reads before
348
+ * reporting Faulted. Debounces transient single-poll FAULT reads (e.g. a
349
+ * DC-coupled battery disturbing a string) that would otherwise oscillate
350
+ * the appliance status. Recovery is immediate (counter resets on the first
351
+ * non-fault read).
352
+ */
353
+ static OPERATING_FAULT_THRESHOLD = 3;
346
354
  storage;
355
+ consecutiveOperatingFaults = 0;
347
356
  errorState = { activeCodes: [], lastStatus: 'healthy' };
348
357
  snapshotService;
349
358
  // Whether we've emitted a status update at least once since (re)connecting.
@@ -656,7 +665,15 @@ class SunspecInverter extends BaseSunspecDevice {
656
665
  // fault; MPPT (4), SLEEPING (2) and the other states are normal. This
657
666
  // is re-evaluated every poll so a producing inverter (operatingState=4,
658
667
  // no error bits) reports Healthy even if it was previously faulted.
659
- const operatingFault = data.operatingState === sunspec_interfaces_js_1.SunspecInverterOperatingState.FAULT;
668
+ //
669
+ // A single FAULT read is debounced: we only treat it as a fault after
670
+ // OPERATING_FAULT_THRESHOLD consecutive FAULT polls, so a transient
671
+ // wobble (e.g. a faulted DC-coupled battery on a string) does not
672
+ // oscillate the status. Recovery is immediate — the counter resets on
673
+ // the first non-fault read. Event-register error bits stay immediate.
674
+ const operatingFaultRaw = data.operatingState === sunspec_interfaces_js_1.SunspecInverterOperatingState.FAULT;
675
+ this.consecutiveOperatingFaults = operatingFaultRaw ? this.consecutiveOperatingFaults + 1 : 0;
676
+ const operatingFault = this.consecutiveOperatingFaults >= SunspecInverter.OPERATING_FAULT_THRESHOLD;
660
677
  const newStatus = (codeIds.length > 0 || operatingFault)
661
678
  ? enyo_appliance_js_1.EnyoApplianceStatusEnum.Faulted
662
679
  : enyo_appliance_js_1.EnyoApplianceStatusEnum.Healthy;
@@ -772,7 +789,10 @@ class SunspecInverter extends BaseSunspecDevice {
772
789
  // Only include strings with valid data
773
790
  if (mppt.dcVoltage !== undefined || mppt.dcPower !== undefined) {
774
791
  result.push({
775
- index: index + 1,
792
+ // Use the stable physical module position so a string keeps
793
+ // its identity when an earlier module drops out of a poll.
794
+ // Fall back to array position if the field is absent.
795
+ index: mppt.moduleNumber ?? (index + 1),
776
796
  name: mppt.stringId,
777
797
  state: this.mapMPPTOperatingState(mppt.operatingState),
778
798
  current: mppt.dcCurrent,
@@ -1676,6 +1696,20 @@ class SunspecMeter extends BaseSunspecDevice {
1676
1696
  super(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId, port, baseAddress, retryConfig, appliance, useTls);
1677
1697
  this.capabilities = capabilities;
1678
1698
  }
1699
+ /**
1700
+ * The specific meter model instance this meter reads, when its unit hosts more than one
1701
+ * embedded meter (e.g. a SolarEdge SE16K with the inverter + multiple meters on unit 1).
1702
+ * When set, identity and telemetry are read from THIS block and its own preceding Common
1703
+ * block rather than the unit's first/only meter. Unset → legacy single-meter behavior.
1704
+ */
1705
+ meterModel;
1706
+ /**
1707
+ * Bind this meter to a specific discovered meter model instance (from
1708
+ * {@link SunspecModbusClient.findModels}). Call before {@link connect}.
1709
+ */
1710
+ bindMeterModel(model) {
1711
+ this.meterModel = model;
1712
+ }
1679
1713
  /**
1680
1714
  * Connect to the meter and create/update the appliance
1681
1715
  */
@@ -1683,7 +1717,8 @@ class SunspecMeter extends BaseSunspecDevice {
1683
1717
  // Connect with specific unit ID for meter
1684
1718
  await this.ensureConnected();
1685
1719
  // Check if meter models exist (int+SF: 201/203/204, float: 211/212/213/214)
1686
- const hasMeter = this.sunspecClient.findModel(this.unitId, sunspec_interfaces_js_1.SunspecModelId.Meter3Phase) !== undefined ||
1720
+ const hasMeter = this.meterModel !== undefined ||
1721
+ this.sunspecClient.findModel(this.unitId, sunspec_interfaces_js_1.SunspecModelId.Meter3Phase) !== undefined ||
1687
1722
  this.sunspecClient.findModel(this.unitId, sunspec_interfaces_js_1.SunspecModelId.MeterWye) !== undefined ||
1688
1723
  this.sunspecClient.findModel(this.unitId, sunspec_interfaces_js_1.SunspecModelId.MeterSinglePhase) !== undefined ||
1689
1724
  this.sunspecClient.findModel(this.unitId, sunspec_interfaces_js_1.SunspecModelId.Meter3PhaseWyeFloat) !== undefined ||
@@ -1693,8 +1728,12 @@ class SunspecMeter extends BaseSunspecDevice {
1693
1728
  if (!hasMeter) {
1694
1729
  throw new Error('No meter model found in device');
1695
1730
  }
1696
- // Get device info
1697
- const commonData = await this.sunspecClient.readCommonBlock(this.unitId);
1731
+ // Get device info. For a bound meter instance, read ITS own Common block (the one
1732
+ // preceding its meter model) so the appliance gets the meter's own manufacturer/model/
1733
+ // serial — not the inverter's or a sibling meter's (the unit's first Common).
1734
+ const commonData = this.meterModel
1735
+ ? await this.sunspecClient.readCommonBlockForModel(this.unitId, this.meterModel)
1736
+ : await this.sunspecClient.readCommonBlock(this.unitId);
1698
1737
  // Create or update appliance (skip if an existing appliance was provided)
1699
1738
  if (!this.applianceId) {
1700
1739
  try {
@@ -1768,8 +1807,8 @@ class SunspecMeter extends BaseSunspecDevice {
1768
1807
  const messages = [];
1769
1808
  const timestamp = new Date();
1770
1809
  try {
1771
- // Read meter data
1772
- const meterData = await this.sunspecClient.readMeterData(this.unitId);
1810
+ // Read meter data (from the bound instance when this unit hosts multiple meters)
1811
+ const meterData = await this.sunspecClient.readMeterData(this.unitId, this.meterModel);
1773
1812
  // SDK readers swallow modbus errors and return null; detect that here so the
1774
1813
  // appliance is marked offline and the retry manager starts its backoff.
1775
1814
  if (await this.markOfflineIfUnhealthy()) {
@@ -3,6 +3,7 @@ import { ApplianceManager, EnergyApp } from "@enyo-energy/energy-app-sdk";
3
3
  import { type EnyoAppliance, type EnyoApplianceErrorCode, EnyoApplianceName } from "@enyo-energy/energy-app-sdk/dist/types/enyo-appliance.js";
4
4
  import { EnyoNetworkDevice } from "@enyo-energy/energy-app-sdk/dist/types/enyo-network-device.js";
5
5
  import { SunspecModbusClient } from "./sunspec-modbus-client.cjs";
6
+ import { SunspecModel } from "./sunspec-interfaces.cjs";
6
7
  import { ConnectionRetryManager } from "./connection-retry-manager.cjs";
7
8
  import { EnyoCommandAcknowledgeAnswerEnum, EnyoDataBusMessage, EnyoDataBusMessageEnum, EnyoDataBusMessageResolution } from "@enyo-energy/energy-app-sdk/dist/types/enyo-data-bus-value.js";
8
9
  import { BatteryCalibrator, type BatteryCalibratorConfig, type CalibrationResult, type CalibrationResultStore, type CalibrationSnapshot, type RestoreReason, SnapshotService } from "@enyo-energy/appliance-calibration";
@@ -128,7 +129,16 @@ export declare class SunspecInverter extends BaseSunspecDevice {
128
129
  private readonly capabilities;
129
130
  /** Emit a connection-lost faulted status after this many consecutive failed reconnect attempts. */
130
131
  private static readonly CONNECTION_FAULT_THRESHOLD;
132
+ /**
133
+ * Require this many consecutive operatingState=7 (FAULT) reads before
134
+ * reporting Faulted. Debounces transient single-poll FAULT reads (e.g. a
135
+ * DC-coupled battery disturbing a string) that would otherwise oscillate
136
+ * the appliance status. Recovery is immediate (counter resets on the first
137
+ * non-fault read).
138
+ */
139
+ private static readonly OPERATING_FAULT_THRESHOLD;
131
140
  private storage?;
141
+ private consecutiveOperatingFaults;
132
142
  private errorState;
133
143
  private snapshotService?;
134
144
  private statusReassertedThisSession;
@@ -469,6 +479,18 @@ export declare class SunspecBattery extends BaseSunspecDevice {
469
479
  export declare class SunspecMeter extends BaseSunspecDevice {
470
480
  private readonly capabilities;
471
481
  constructor(energyApp: EnergyApp, name: EnyoApplianceName[], networkDevice: EnyoNetworkDevice, sunspecClient: SunspecModbusClient, applianceManager: ApplianceManager, unitId?: number, port?: number, baseAddress?: number, capabilities?: SunspecMeterCapability[], retryConfig?: IRetryConfig, appliance?: EnyoAppliance, useTls?: boolean);
482
+ /**
483
+ * The specific meter model instance this meter reads, when its unit hosts more than one
484
+ * embedded meter (e.g. a SolarEdge SE16K with the inverter + multiple meters on unit 1).
485
+ * When set, identity and telemetry are read from THIS block and its own preceding Common
486
+ * block rather than the unit's first/only meter. Unset → legacy single-meter behavior.
487
+ */
488
+ private meterModel?;
489
+ /**
490
+ * Bind this meter to a specific discovered meter model instance (from
491
+ * {@link SunspecModbusClient.findModels}). Call before {@link connect}.
492
+ */
493
+ bindMeterModel(model: SunspecModel): void;
472
494
  /**
473
495
  * Connect to the meter and create/update the appliance
474
496
  */
@@ -190,6 +190,7 @@ export interface SunspecInverterPersistedErrorState {
190
190
  */
191
191
  export interface SunspecMPPTData extends SunspecBlock {
192
192
  blockNumber: 160;
193
+ moduleNumber?: number;
193
194
  id: number;
194
195
  stringId?: string;
195
196
  dcCurrent?: number;
@@ -83,7 +83,15 @@ class SunspecModbusClient {
83
83
  // many unit IDs share that client (each unit ID is one EnergyAppModbusInstance underneath).
84
84
  modbusInstances = new Map();
85
85
  faultTolerantReaders = new Map();
86
+ // First-instance-wins directory (lowest address per model id). SunSpec allows a model id to
87
+ // repeat on one unit — most notably Common (id 1), which appears once per logical device
88
+ // (inverter, then each embedded meter). Keyed lookups want the PRIMARY (first) instance, so
89
+ // findModel/readCommonBlock resolve to the device's own block, not a later meter's.
86
90
  discoveredModelsByUnit = new Map();
91
+ // Full address-ordered list of every discovered instance, including repeats. Used by
92
+ // findModels()/readCommonBlockForModel() to enumerate multiple meters and pair each meter
93
+ // block with its own preceding Common block.
94
+ discoveredModelListByUnit = new Map();
87
95
  connectionHealth;
88
96
  modbusDataTypeConverter;
89
97
  connectionParams = null;
@@ -306,6 +314,7 @@ class SunspecModbusClient {
306
314
  this.modbusInstances.delete(unitId);
307
315
  this.faultTolerantReaders.delete(unitId);
308
316
  this.discoveredModelsByUnit.delete(unitId);
317
+ this.discoveredModelListByUnit.delete(unitId);
309
318
  if (instance) {
310
319
  try {
311
320
  await instance.disconnect();
@@ -376,6 +385,17 @@ class SunspecModbusClient {
376
385
  }
377
386
  return m;
378
387
  }
388
+ /**
389
+ * Get (or create) the address-ordered list of every discovered model instance for a unit.
390
+ */
391
+ getModelList(unitId) {
392
+ let list = this.discoveredModelListByUnit.get(unitId);
393
+ if (!list) {
394
+ list = [];
395
+ this.discoveredModelListByUnit.set(unitId, list);
396
+ }
397
+ return list;
398
+ }
379
399
  /**
380
400
  * Enable or disable automatic reconnection
381
401
  */
@@ -473,6 +493,8 @@ class SunspecModbusClient {
473
493
  const instance = this.getInstance(unitId);
474
494
  const models = this.getModelsMap(unitId);
475
495
  models.clear();
496
+ const modelList = this.getModelList(unitId);
497
+ modelList.length = 0;
476
498
  const maxAddress = 50000; // Safety limit
477
499
  let currentAddress = 0;
478
500
  console.log(`Starting Sunspec model discovery for unit ${unitId}...`);
@@ -514,7 +536,13 @@ class SunspecModbusClient {
514
536
  address: currentAddress,
515
537
  length: modelLength
516
538
  };
517
- models.set(modelId, model);
539
+ // Keep the FIRST (lowest-address) instance per id in the keyed directory so
540
+ // findModel() resolves to the device's own block. Record every instance —
541
+ // including repeats like a meter's Common (id 1) — in the ordered list.
542
+ if (!models.has(modelId)) {
543
+ models.set(modelId, model);
544
+ }
545
+ modelList.push(model);
518
546
  // Per-model discovery step. The end-of-discovery summary below is the
519
547
  // info-level outcome; the per-model walk is debug-only.
520
548
  console.debug(`Discovered Model ${modelId} at address ${currentAddress} with length ${modelLength} (unit ${unitId})`);
@@ -525,7 +553,9 @@ class SunspecModbusClient {
525
553
  catch (error) {
526
554
  console.error(`Error during model discovery at address ${currentAddress} (unit ${unitId}): ${error}`);
527
555
  }
528
- console.log(`Discovery complete for unit ${unitId}. Found ${models.size} models: [${[...models.keys()].sort((a, b) => a - b).join(', ')}]`);
556
+ // Report every instance in address order so repeated ids (e.g. two Common blocks, or
557
+ // multiple meters) are visible — models.size would hide them behind the unique keys.
558
+ console.log(`Discovery complete for unit ${unitId}. Found ${modelList.length} model instance(s): [${modelList.map(m => m.id).join(', ')}]`);
529
559
  return models;
530
560
  }
531
561
  /**
@@ -534,6 +564,17 @@ class SunspecModbusClient {
534
564
  findModel(unitId, modelId) {
535
565
  return this.discoveredModelsByUnit.get(unitId)?.get(modelId);
536
566
  }
567
+ /**
568
+ * Find every instance of a model id on a unit, in ascending address order.
569
+ *
570
+ * SunSpec permits a model id to repeat on a single unit — e.g. SolarEdge SE16K-class
571
+ * inverters expose the inverter, then one or more embedded meters, on the same Modbus unit,
572
+ * each meter advertising its own meter model (203/204/...) and its own Common block (id 1).
573
+ * `findModel` only returns the first instance; use this to enumerate them all.
574
+ */
575
+ findModels(unitId, modelId) {
576
+ return (this.discoveredModelListByUnit.get(unitId) ?? []).filter(m => m.id === modelId);
577
+ }
537
578
  /**
538
579
  * Check if a value is "unimplemented" according to Sunspec specification
539
580
  * Returns true if the value represents an unimplemented/not applicable register
@@ -1213,6 +1254,10 @@ class SunspecModbusClient {
1213
1254
  (data.dcCurrent !== undefined ||
1214
1255
  data.dcVoltage !== undefined ||
1215
1256
  data.dcPower !== undefined)) {
1257
+ // Stamp the stable 1-based module position so downstream
1258
+ // string identity does not shift when an earlier module
1259
+ // drops out of a poll (e.g. transient undefined reads).
1260
+ data.moduleNumber = i;
1216
1261
  mpptData.push(data);
1217
1262
  }
1218
1263
  }
@@ -1728,8 +1773,17 @@ class SunspecModbusClient {
1728
1773
  // governing parameters are already in place when the device
1729
1774
  // transitions into the new mode.
1730
1775
  if (controls.storCtlMod !== undefined) {
1731
- await this.writeRegisterValue(unitId, baseAddr + 5, controls.storCtlMod, 'uint16');
1732
- console.debug(`Set storage control mode to 0x${controls.storCtlMod.toString(16)}`);
1776
+ // storCtlMod = 0 is rejected with Modbus Exception code 3 (Illegal Data Value)
1777
+ // by some devices (e.g. Fronius GEN24). 0 means "no external control" per the
1778
+ // SunSpec spec, but Fronius firmware requires at least one bit to be set.
1779
+ // Mapping 0 -> CHARGE | DISCHARGE returns the battery to autonomous
1780
+ // self-managed operation on all known SunSpec devices (matches the neutral
1781
+ // reset state used in sunspec-battery-feature-calibrator.ts).
1782
+ const safeMode = controls.storCtlMod === 0
1783
+ ? sunspec_interfaces_js_1.SunspecStorageControlMode.CHARGE | sunspec_interfaces_js_1.SunspecStorageControlMode.DISCHARGE
1784
+ : controls.storCtlMod;
1785
+ await this.writeRegisterValue(unitId, baseAddr + 5, safeMode, 'uint16');
1786
+ console.debug(`Set storage control mode to 0x${safeMode.toString(16)}`);
1733
1787
  }
1734
1788
  console.debug('Battery controls written successfully');
1735
1789
  return true;
@@ -1834,7 +1888,16 @@ class SunspecModbusClient {
1834
1888
  * When a device advertises BOTH encodings, picks the family with more discovered models
1835
1889
  * for this unit (see getPreferredEncoding). Never reads both.
1836
1890
  */
1837
- async readMeterData(unitId) {
1891
+ async readMeterData(unitId, meterModel) {
1892
+ // When a specific meter instance is given (a unit with multiple embedded meters), read
1893
+ // exactly that block — don't let findModel pick the first/only directory entry.
1894
+ if (meterModel) {
1895
+ console.debug(`Using bound meter Model ${meterModel.id} at address ${meterModel.address} (length ${meterModel.length}) on unit ${unitId}`);
1896
+ const floatIds = [211, 212, 213, 214];
1897
+ return floatIds.includes(meterModel.id)
1898
+ ? this.readFloatMeterData(unitId, meterModel, meterModel.id)
1899
+ : this.readIntSfMeterData(unitId, meterModel);
1900
+ }
1838
1901
  const preferred = this.getPreferredEncoding(unitId);
1839
1902
  const tryFloat = async () => {
1840
1903
  const floatIds = [213, 214, 212, 211];
@@ -1992,7 +2055,12 @@ class SunspecModbusClient {
1992
2055
  }
1993
2056
  }
1994
2057
  /**
1995
- * Read common block data (Model 1)
2058
+ * Read common block data (Model 1).
2059
+ *
2060
+ * Resolves to the unit's FIRST Common block — the leading device's own identity (the
2061
+ * inverter on a SolarEdge SE16K-class unit). When a unit embeds meters, each meter has its
2062
+ * own later Common block; read those with {@link readCommonBlockForModel} so a meter reports
2063
+ * its own manufacturer/model/serial instead of the inverter's (or a sibling meter's).
1996
2064
  */
1997
2065
  async readCommonBlock(unitId) {
1998
2066
  const model = this.findModel(unitId, sunspec_interfaces_js_1.SunspecModelId.Common);
@@ -2002,6 +2070,31 @@ class SunspecModbusClient {
2002
2070
  console.debug(`Common block model not found on unit ${unitId}`);
2003
2071
  return null;
2004
2072
  }
2073
+ return this.readCommonBlockAt(unitId, model);
2074
+ }
2075
+ /**
2076
+ * Read the Common (id 1) block that belongs to a specific device model — the Common instance
2077
+ * immediately preceding the model's address. SunSpec lays out each logical device as
2078
+ * `Common → device model(s)`, so a meter's identity is the Common block right before its meter
2079
+ * model. Falls back to the unit's first Common when none precedes (shouldn't happen for a
2080
+ * well-formed map). Used to give each embedded meter its own appliance identity.
2081
+ */
2082
+ async readCommonBlockForModel(unitId, deviceModel) {
2083
+ const preceding = this.findModels(unitId, sunspec_interfaces_js_1.SunspecModelId.Common)
2084
+ .filter(c => c.address <= deviceModel.address)
2085
+ .sort((a, b) => a.address - b.address)
2086
+ .pop();
2087
+ const model = preceding ?? this.findModel(unitId, sunspec_interfaces_js_1.SunspecModelId.Common);
2088
+ if (!model) {
2089
+ console.debug(`Common block model not found on unit ${unitId} for model ${deviceModel.id}@${deviceModel.address}`);
2090
+ return null;
2091
+ }
2092
+ return this.readCommonBlockAt(unitId, model);
2093
+ }
2094
+ /**
2095
+ * Decode a given Common (Model 1) block instance into identity fields.
2096
+ */
2097
+ async readCommonBlockAt(unitId, model) {
2005
2098
  console.debug(`Reading Common Block - Model address: ${model.address} (unit ${unitId})`);
2006
2099
  try {
2007
2100
  // Read entire model block in a single Modbus call
@@ -37,6 +37,7 @@ export declare class SunspecModbusClient {
37
37
  private modbusInstances;
38
38
  private faultTolerantReaders;
39
39
  private discoveredModelsByUnit;
40
+ private discoveredModelListByUnit;
40
41
  private connectionHealth;
41
42
  private modbusDataTypeConverter;
42
43
  private connectionParams;
@@ -128,6 +129,10 @@ export declare class SunspecModbusClient {
128
129
  * Get (or create) the discovered-models map for a unit.
129
130
  */
130
131
  private getModelsMap;
132
+ /**
133
+ * Get (or create) the address-ordered list of every discovered model instance for a unit.
134
+ */
135
+ private getModelList;
131
136
  /**
132
137
  * Enable or disable automatic reconnection
133
138
  */
@@ -153,6 +158,15 @@ export declare class SunspecModbusClient {
153
158
  * Find a specific model by ID for a given unit
154
159
  */
155
160
  findModel(unitId: number, modelId: number): SunspecModel | undefined;
161
+ /**
162
+ * Find every instance of a model id on a unit, in ascending address order.
163
+ *
164
+ * SunSpec permits a model id to repeat on a single unit — e.g. SolarEdge SE16K-class
165
+ * inverters expose the inverter, then one or more embedded meters, on the same Modbus unit,
166
+ * each meter advertising its own meter model (203/204/...) and its own Common block (id 1).
167
+ * `findModel` only returns the first instance; use this to enumerate them all.
168
+ */
169
+ findModels(unitId: number, modelId: number): SunspecModel[];
156
170
  /**
157
171
  * Check if a value is "unimplemented" according to Sunspec specification
158
172
  * Returns true if the value represents an unimplemented/not applicable register
@@ -337,7 +351,7 @@ export declare class SunspecModbusClient {
337
351
  * When a device advertises BOTH encodings, picks the family with more discovered models
338
352
  * for this unit (see getPreferredEncoding). Never reads both.
339
353
  */
340
- readMeterData(unitId: number): Promise<SunspecMeterData | null>;
354
+ readMeterData(unitId: number, meterModel?: SunspecModel): Promise<SunspecMeterData | null>;
341
355
  /**
342
356
  * Read meter data from an int+SF variant model (201/203/204). Extracted from the original
343
357
  * readMeterData body so the preferred-encoding dispatcher above can branch cleanly without
@@ -362,9 +376,26 @@ export declare class SunspecModbusClient {
362
376
  */
363
377
  private readFloatMeterData;
364
378
  /**
365
- * Read common block data (Model 1)
379
+ * Read common block data (Model 1).
380
+ *
381
+ * Resolves to the unit's FIRST Common block — the leading device's own identity (the
382
+ * inverter on a SolarEdge SE16K-class unit). When a unit embeds meters, each meter has its
383
+ * own later Common block; read those with {@link readCommonBlockForModel} so a meter reports
384
+ * its own manufacturer/model/serial instead of the inverter's (or a sibling meter's).
366
385
  */
367
386
  readCommonBlock(unitId: number): Promise<any>;
387
+ /**
388
+ * Read the Common (id 1) block that belongs to a specific device model — the Common instance
389
+ * immediately preceding the model's address. SunSpec lays out each logical device as
390
+ * `Common → device model(s)`, so a meter's identity is the Common block right before its meter
391
+ * model. Falls back to the unit's first Common when none precedes (shouldn't happen for a
392
+ * well-formed map). Used to give each embedded meter its own appliance identity.
393
+ */
394
+ readCommonBlockForModel(unitId: number, deviceModel: SunspecModel): Promise<any>;
395
+ /**
396
+ * Decode a given Common (Model 1) block instance into identity fields.
397
+ */
398
+ private readCommonBlockAt;
368
399
  /**
369
400
  * Get serial number from device
370
401
  */
@@ -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.83';
12
+ exports.SDK_VERSION = '0.0.85';
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.83";
8
+ export declare const SDK_VERSION = "0.0.85";
9
9
  /**
10
10
  * Gets the current SDK version.
11
11
  * @returns The semantic version string of the SDK
@@ -70,22 +70,30 @@ export class SunspecBatteryScheduleHandler extends StorageScheduleHandler {
70
70
  // baseline so the persisted snapshot continues to point at the
71
71
  // original pre-schedule state. The library will overwrite its
72
72
  // storage row with the same value — harmless.
73
- this.installedWChaMax = this.originalBaseline.wChaMax;
73
+ const wChaMax = this.originalBaseline.wChaMax;
74
+ if (!wChaMax || wChaMax <= 0) {
75
+ throw new Error(`SunspecBatteryScheduleHandler ${this.applianceIdForLog}: cannot start schedule — originalBaseline has no usable wChaMax (got ${wChaMax})`);
76
+ }
77
+ this.installedWChaMax = wChaMax;
74
78
  return { ...this.originalBaseline };
75
79
  }
76
80
  const controls = await this.sunspecClient.readBatteryControls(this.unitId);
77
81
  if (!controls) {
78
82
  throw new Error(`SunspecBatteryScheduleHandler ${this.applianceIdForLog}: failed to read pre-schedule controls`);
79
83
  }
84
+ const wChaMax = controls.wChaMax;
85
+ if (!wChaMax || wChaMax <= 0) {
86
+ throw new Error(`SunspecBatteryScheduleHandler ${this.applianceIdForLog}: cannot start schedule — device did not report a usable wChaMax (got ${wChaMax})`);
87
+ }
80
88
  const baseline = {
81
89
  storCtlMod: controls.storCtlMod,
82
90
  chaGriSet: controls.chaGriSet,
83
- wChaMax: controls.wChaMax,
91
+ wChaMax,
84
92
  inWRte: controls.inWRte,
85
93
  outWRte: controls.outWRte,
86
94
  };
87
95
  this.originalBaseline = baseline;
88
- this.installedWChaMax = baseline.wChaMax;
96
+ this.installedWChaMax = wChaMax;
89
97
  return { ...baseline };
90
98
  }
91
99
  onChange(active, _previous) {
@@ -3,6 +3,7 @@ import { ApplianceManager, EnergyApp } from "@enyo-energy/energy-app-sdk";
3
3
  import { type EnyoAppliance, type EnyoApplianceErrorCode, EnyoApplianceName } from "@enyo-energy/energy-app-sdk/dist/types/enyo-appliance.js";
4
4
  import { EnyoNetworkDevice } from "@enyo-energy/energy-app-sdk/dist/types/enyo-network-device.js";
5
5
  import { SunspecModbusClient } from "./sunspec-modbus-client.js";
6
+ import { SunspecModel } from "./sunspec-interfaces.js";
6
7
  import { ConnectionRetryManager } from "./connection-retry-manager.js";
7
8
  import { EnyoCommandAcknowledgeAnswerEnum, EnyoDataBusMessage, EnyoDataBusMessageEnum, EnyoDataBusMessageResolution } from "@enyo-energy/energy-app-sdk/dist/types/enyo-data-bus-value.js";
8
9
  import { BatteryCalibrator, type BatteryCalibratorConfig, type CalibrationResult, type CalibrationResultStore, type CalibrationSnapshot, type RestoreReason, SnapshotService } from "@enyo-energy/appliance-calibration";
@@ -128,7 +129,16 @@ export declare class SunspecInverter extends BaseSunspecDevice {
128
129
  private readonly capabilities;
129
130
  /** Emit a connection-lost faulted status after this many consecutive failed reconnect attempts. */
130
131
  private static readonly CONNECTION_FAULT_THRESHOLD;
132
+ /**
133
+ * Require this many consecutive operatingState=7 (FAULT) reads before
134
+ * reporting Faulted. Debounces transient single-poll FAULT reads (e.g. a
135
+ * DC-coupled battery disturbing a string) that would otherwise oscillate
136
+ * the appliance status. Recovery is immediate (counter resets on the first
137
+ * non-fault read).
138
+ */
139
+ private static readonly OPERATING_FAULT_THRESHOLD;
131
140
  private storage?;
141
+ private consecutiveOperatingFaults;
132
142
  private errorState;
133
143
  private snapshotService?;
134
144
  private statusReassertedThisSession;
@@ -469,6 +479,18 @@ export declare class SunspecBattery extends BaseSunspecDevice {
469
479
  export declare class SunspecMeter extends BaseSunspecDevice {
470
480
  private readonly capabilities;
471
481
  constructor(energyApp: EnergyApp, name: EnyoApplianceName[], networkDevice: EnyoNetworkDevice, sunspecClient: SunspecModbusClient, applianceManager: ApplianceManager, unitId?: number, port?: number, baseAddress?: number, capabilities?: SunspecMeterCapability[], retryConfig?: IRetryConfig, appliance?: EnyoAppliance, useTls?: boolean);
482
+ /**
483
+ * The specific meter model instance this meter reads, when its unit hosts more than one
484
+ * embedded meter (e.g. a SolarEdge SE16K with the inverter + multiple meters on unit 1).
485
+ * When set, identity and telemetry are read from THIS block and its own preceding Common
486
+ * block rather than the unit's first/only meter. Unset → legacy single-meter behavior.
487
+ */
488
+ private meterModel?;
489
+ /**
490
+ * Bind this meter to a specific discovered meter model instance (from
491
+ * {@link SunspecModbusClient.findModels}). Call before {@link connect}.
492
+ */
493
+ bindMeterModel(model: SunspecModel): void;
472
494
  /**
473
495
  * Connect to the meter and create/update the appliance
474
496
  */
@@ -336,7 +336,16 @@ export class SunspecInverter extends BaseSunspecDevice {
336
336
  capabilities;
337
337
  /** Emit a connection-lost faulted status after this many consecutive failed reconnect attempts. */
338
338
  static CONNECTION_FAULT_THRESHOLD = 3;
339
+ /**
340
+ * Require this many consecutive operatingState=7 (FAULT) reads before
341
+ * reporting Faulted. Debounces transient single-poll FAULT reads (e.g. a
342
+ * DC-coupled battery disturbing a string) that would otherwise oscillate
343
+ * the appliance status. Recovery is immediate (counter resets on the first
344
+ * non-fault read).
345
+ */
346
+ static OPERATING_FAULT_THRESHOLD = 3;
339
347
  storage;
348
+ consecutiveOperatingFaults = 0;
340
349
  errorState = { activeCodes: [], lastStatus: 'healthy' };
341
350
  snapshotService;
342
351
  // Whether we've emitted a status update at least once since (re)connecting.
@@ -649,7 +658,15 @@ export class SunspecInverter extends BaseSunspecDevice {
649
658
  // fault; MPPT (4), SLEEPING (2) and the other states are normal. This
650
659
  // is re-evaluated every poll so a producing inverter (operatingState=4,
651
660
  // no error bits) reports Healthy even if it was previously faulted.
652
- const operatingFault = data.operatingState === SunspecInverterOperatingState.FAULT;
661
+ //
662
+ // A single FAULT read is debounced: we only treat it as a fault after
663
+ // OPERATING_FAULT_THRESHOLD consecutive FAULT polls, so a transient
664
+ // wobble (e.g. a faulted DC-coupled battery on a string) does not
665
+ // oscillate the status. Recovery is immediate — the counter resets on
666
+ // the first non-fault read. Event-register error bits stay immediate.
667
+ const operatingFaultRaw = data.operatingState === SunspecInverterOperatingState.FAULT;
668
+ this.consecutiveOperatingFaults = operatingFaultRaw ? this.consecutiveOperatingFaults + 1 : 0;
669
+ const operatingFault = this.consecutiveOperatingFaults >= SunspecInverter.OPERATING_FAULT_THRESHOLD;
653
670
  const newStatus = (codeIds.length > 0 || operatingFault)
654
671
  ? EnyoApplianceStatusEnum.Faulted
655
672
  : EnyoApplianceStatusEnum.Healthy;
@@ -765,7 +782,10 @@ export class SunspecInverter extends BaseSunspecDevice {
765
782
  // Only include strings with valid data
766
783
  if (mppt.dcVoltage !== undefined || mppt.dcPower !== undefined) {
767
784
  result.push({
768
- index: index + 1,
785
+ // Use the stable physical module position so a string keeps
786
+ // its identity when an earlier module drops out of a poll.
787
+ // Fall back to array position if the field is absent.
788
+ index: mppt.moduleNumber ?? (index + 1),
769
789
  name: mppt.stringId,
770
790
  state: this.mapMPPTOperatingState(mppt.operatingState),
771
791
  current: mppt.dcCurrent,
@@ -1667,6 +1687,20 @@ export class SunspecMeter extends BaseSunspecDevice {
1667
1687
  super(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId, port, baseAddress, retryConfig, appliance, useTls);
1668
1688
  this.capabilities = capabilities;
1669
1689
  }
1690
+ /**
1691
+ * The specific meter model instance this meter reads, when its unit hosts more than one
1692
+ * embedded meter (e.g. a SolarEdge SE16K with the inverter + multiple meters on unit 1).
1693
+ * When set, identity and telemetry are read from THIS block and its own preceding Common
1694
+ * block rather than the unit's first/only meter. Unset → legacy single-meter behavior.
1695
+ */
1696
+ meterModel;
1697
+ /**
1698
+ * Bind this meter to a specific discovered meter model instance (from
1699
+ * {@link SunspecModbusClient.findModels}). Call before {@link connect}.
1700
+ */
1701
+ bindMeterModel(model) {
1702
+ this.meterModel = model;
1703
+ }
1670
1704
  /**
1671
1705
  * Connect to the meter and create/update the appliance
1672
1706
  */
@@ -1674,7 +1708,8 @@ export class SunspecMeter extends BaseSunspecDevice {
1674
1708
  // Connect with specific unit ID for meter
1675
1709
  await this.ensureConnected();
1676
1710
  // Check if meter models exist (int+SF: 201/203/204, float: 211/212/213/214)
1677
- const hasMeter = this.sunspecClient.findModel(this.unitId, SunspecModelId.Meter3Phase) !== undefined ||
1711
+ const hasMeter = this.meterModel !== undefined ||
1712
+ this.sunspecClient.findModel(this.unitId, SunspecModelId.Meter3Phase) !== undefined ||
1678
1713
  this.sunspecClient.findModel(this.unitId, SunspecModelId.MeterWye) !== undefined ||
1679
1714
  this.sunspecClient.findModel(this.unitId, SunspecModelId.MeterSinglePhase) !== undefined ||
1680
1715
  this.sunspecClient.findModel(this.unitId, SunspecModelId.Meter3PhaseWyeFloat) !== undefined ||
@@ -1684,8 +1719,12 @@ export class SunspecMeter extends BaseSunspecDevice {
1684
1719
  if (!hasMeter) {
1685
1720
  throw new Error('No meter model found in device');
1686
1721
  }
1687
- // Get device info
1688
- const commonData = await this.sunspecClient.readCommonBlock(this.unitId);
1722
+ // Get device info. For a bound meter instance, read ITS own Common block (the one
1723
+ // preceding its meter model) so the appliance gets the meter's own manufacturer/model/
1724
+ // serial — not the inverter's or a sibling meter's (the unit's first Common).
1725
+ const commonData = this.meterModel
1726
+ ? await this.sunspecClient.readCommonBlockForModel(this.unitId, this.meterModel)
1727
+ : await this.sunspecClient.readCommonBlock(this.unitId);
1689
1728
  // Create or update appliance (skip if an existing appliance was provided)
1690
1729
  if (!this.applianceId) {
1691
1730
  try {
@@ -1759,8 +1798,8 @@ export class SunspecMeter extends BaseSunspecDevice {
1759
1798
  const messages = [];
1760
1799
  const timestamp = new Date();
1761
1800
  try {
1762
- // Read meter data
1763
- const meterData = await this.sunspecClient.readMeterData(this.unitId);
1801
+ // Read meter data (from the bound instance when this unit hosts multiple meters)
1802
+ const meterData = await this.sunspecClient.readMeterData(this.unitId, this.meterModel);
1764
1803
  // SDK readers swallow modbus errors and return null; detect that here so the
1765
1804
  // appliance is marked offline and the retry manager starts its backoff.
1766
1805
  if (await this.markOfflineIfUnhealthy()) {
@@ -190,6 +190,7 @@ export interface SunspecInverterPersistedErrorState {
190
190
  */
191
191
  export interface SunspecMPPTData extends SunspecBlock {
192
192
  blockNumber: 160;
193
+ moduleNumber?: number;
193
194
  id: number;
194
195
  stringId?: string;
195
196
  dcCurrent?: number;
@@ -37,6 +37,7 @@ export declare class SunspecModbusClient {
37
37
  private modbusInstances;
38
38
  private faultTolerantReaders;
39
39
  private discoveredModelsByUnit;
40
+ private discoveredModelListByUnit;
40
41
  private connectionHealth;
41
42
  private modbusDataTypeConverter;
42
43
  private connectionParams;
@@ -128,6 +129,10 @@ export declare class SunspecModbusClient {
128
129
  * Get (or create) the discovered-models map for a unit.
129
130
  */
130
131
  private getModelsMap;
132
+ /**
133
+ * Get (or create) the address-ordered list of every discovered model instance for a unit.
134
+ */
135
+ private getModelList;
131
136
  /**
132
137
  * Enable or disable automatic reconnection
133
138
  */
@@ -153,6 +158,15 @@ export declare class SunspecModbusClient {
153
158
  * Find a specific model by ID for a given unit
154
159
  */
155
160
  findModel(unitId: number, modelId: number): SunspecModel | undefined;
161
+ /**
162
+ * Find every instance of a model id on a unit, in ascending address order.
163
+ *
164
+ * SunSpec permits a model id to repeat on a single unit — e.g. SolarEdge SE16K-class
165
+ * inverters expose the inverter, then one or more embedded meters, on the same Modbus unit,
166
+ * each meter advertising its own meter model (203/204/...) and its own Common block (id 1).
167
+ * `findModel` only returns the first instance; use this to enumerate them all.
168
+ */
169
+ findModels(unitId: number, modelId: number): SunspecModel[];
156
170
  /**
157
171
  * Check if a value is "unimplemented" according to Sunspec specification
158
172
  * Returns true if the value represents an unimplemented/not applicable register
@@ -337,7 +351,7 @@ export declare class SunspecModbusClient {
337
351
  * When a device advertises BOTH encodings, picks the family with more discovered models
338
352
  * for this unit (see getPreferredEncoding). Never reads both.
339
353
  */
340
- readMeterData(unitId: number): Promise<SunspecMeterData | null>;
354
+ readMeterData(unitId: number, meterModel?: SunspecModel): Promise<SunspecMeterData | null>;
341
355
  /**
342
356
  * Read meter data from an int+SF variant model (201/203/204). Extracted from the original
343
357
  * readMeterData body so the preferred-encoding dispatcher above can branch cleanly without
@@ -362,9 +376,26 @@ export declare class SunspecModbusClient {
362
376
  */
363
377
  private readFloatMeterData;
364
378
  /**
365
- * Read common block data (Model 1)
379
+ * Read common block data (Model 1).
380
+ *
381
+ * Resolves to the unit's FIRST Common block — the leading device's own identity (the
382
+ * inverter on a SolarEdge SE16K-class unit). When a unit embeds meters, each meter has its
383
+ * own later Common block; read those with {@link readCommonBlockForModel} so a meter reports
384
+ * its own manufacturer/model/serial instead of the inverter's (or a sibling meter's).
366
385
  */
367
386
  readCommonBlock(unitId: number): Promise<any>;
387
+ /**
388
+ * Read the Common (id 1) block that belongs to a specific device model — the Common instance
389
+ * immediately preceding the model's address. SunSpec lays out each logical device as
390
+ * `Common → device model(s)`, so a meter's identity is the Common block right before its meter
391
+ * model. Falls back to the unit's first Common when none precedes (shouldn't happen for a
392
+ * well-formed map). Used to give each embedded meter its own appliance identity.
393
+ */
394
+ readCommonBlockForModel(unitId: number, deviceModel: SunspecModel): Promise<any>;
395
+ /**
396
+ * Decode a given Common (Model 1) block instance into identity fields.
397
+ */
398
+ private readCommonBlockAt;
368
399
  /**
369
400
  * Get serial number from device
370
401
  */
@@ -78,7 +78,15 @@ export class SunspecModbusClient {
78
78
  // many unit IDs share that client (each unit ID is one EnergyAppModbusInstance underneath).
79
79
  modbusInstances = new Map();
80
80
  faultTolerantReaders = new Map();
81
+ // First-instance-wins directory (lowest address per model id). SunSpec allows a model id to
82
+ // repeat on one unit — most notably Common (id 1), which appears once per logical device
83
+ // (inverter, then each embedded meter). Keyed lookups want the PRIMARY (first) instance, so
84
+ // findModel/readCommonBlock resolve to the device's own block, not a later meter's.
81
85
  discoveredModelsByUnit = new Map();
86
+ // Full address-ordered list of every discovered instance, including repeats. Used by
87
+ // findModels()/readCommonBlockForModel() to enumerate multiple meters and pair each meter
88
+ // block with its own preceding Common block.
89
+ discoveredModelListByUnit = new Map();
82
90
  connectionHealth;
83
91
  modbusDataTypeConverter;
84
92
  connectionParams = null;
@@ -301,6 +309,7 @@ export class SunspecModbusClient {
301
309
  this.modbusInstances.delete(unitId);
302
310
  this.faultTolerantReaders.delete(unitId);
303
311
  this.discoveredModelsByUnit.delete(unitId);
312
+ this.discoveredModelListByUnit.delete(unitId);
304
313
  if (instance) {
305
314
  try {
306
315
  await instance.disconnect();
@@ -371,6 +380,17 @@ export class SunspecModbusClient {
371
380
  }
372
381
  return m;
373
382
  }
383
+ /**
384
+ * Get (or create) the address-ordered list of every discovered model instance for a unit.
385
+ */
386
+ getModelList(unitId) {
387
+ let list = this.discoveredModelListByUnit.get(unitId);
388
+ if (!list) {
389
+ list = [];
390
+ this.discoveredModelListByUnit.set(unitId, list);
391
+ }
392
+ return list;
393
+ }
374
394
  /**
375
395
  * Enable or disable automatic reconnection
376
396
  */
@@ -468,6 +488,8 @@ export class SunspecModbusClient {
468
488
  const instance = this.getInstance(unitId);
469
489
  const models = this.getModelsMap(unitId);
470
490
  models.clear();
491
+ const modelList = this.getModelList(unitId);
492
+ modelList.length = 0;
471
493
  const maxAddress = 50000; // Safety limit
472
494
  let currentAddress = 0;
473
495
  console.log(`Starting Sunspec model discovery for unit ${unitId}...`);
@@ -509,7 +531,13 @@ export class SunspecModbusClient {
509
531
  address: currentAddress,
510
532
  length: modelLength
511
533
  };
512
- models.set(modelId, model);
534
+ // Keep the FIRST (lowest-address) instance per id in the keyed directory so
535
+ // findModel() resolves to the device's own block. Record every instance —
536
+ // including repeats like a meter's Common (id 1) — in the ordered list.
537
+ if (!models.has(modelId)) {
538
+ models.set(modelId, model);
539
+ }
540
+ modelList.push(model);
513
541
  // Per-model discovery step. The end-of-discovery summary below is the
514
542
  // info-level outcome; the per-model walk is debug-only.
515
543
  console.debug(`Discovered Model ${modelId} at address ${currentAddress} with length ${modelLength} (unit ${unitId})`);
@@ -520,7 +548,9 @@ export class SunspecModbusClient {
520
548
  catch (error) {
521
549
  console.error(`Error during model discovery at address ${currentAddress} (unit ${unitId}): ${error}`);
522
550
  }
523
- console.log(`Discovery complete for unit ${unitId}. Found ${models.size} models: [${[...models.keys()].sort((a, b) => a - b).join(', ')}]`);
551
+ // Report every instance in address order so repeated ids (e.g. two Common blocks, or
552
+ // multiple meters) are visible — models.size would hide them behind the unique keys.
553
+ console.log(`Discovery complete for unit ${unitId}. Found ${modelList.length} model instance(s): [${modelList.map(m => m.id).join(', ')}]`);
524
554
  return models;
525
555
  }
526
556
  /**
@@ -529,6 +559,17 @@ export class SunspecModbusClient {
529
559
  findModel(unitId, modelId) {
530
560
  return this.discoveredModelsByUnit.get(unitId)?.get(modelId);
531
561
  }
562
+ /**
563
+ * Find every instance of a model id on a unit, in ascending address order.
564
+ *
565
+ * SunSpec permits a model id to repeat on a single unit — e.g. SolarEdge SE16K-class
566
+ * inverters expose the inverter, then one or more embedded meters, on the same Modbus unit,
567
+ * each meter advertising its own meter model (203/204/...) and its own Common block (id 1).
568
+ * `findModel` only returns the first instance; use this to enumerate them all.
569
+ */
570
+ findModels(unitId, modelId) {
571
+ return (this.discoveredModelListByUnit.get(unitId) ?? []).filter(m => m.id === modelId);
572
+ }
532
573
  /**
533
574
  * Check if a value is "unimplemented" according to Sunspec specification
534
575
  * Returns true if the value represents an unimplemented/not applicable register
@@ -1208,6 +1249,10 @@ export class SunspecModbusClient {
1208
1249
  (data.dcCurrent !== undefined ||
1209
1250
  data.dcVoltage !== undefined ||
1210
1251
  data.dcPower !== undefined)) {
1252
+ // Stamp the stable 1-based module position so downstream
1253
+ // string identity does not shift when an earlier module
1254
+ // drops out of a poll (e.g. transient undefined reads).
1255
+ data.moduleNumber = i;
1211
1256
  mpptData.push(data);
1212
1257
  }
1213
1258
  }
@@ -1723,8 +1768,17 @@ export class SunspecModbusClient {
1723
1768
  // governing parameters are already in place when the device
1724
1769
  // transitions into the new mode.
1725
1770
  if (controls.storCtlMod !== undefined) {
1726
- await this.writeRegisterValue(unitId, baseAddr + 5, controls.storCtlMod, 'uint16');
1727
- console.debug(`Set storage control mode to 0x${controls.storCtlMod.toString(16)}`);
1771
+ // storCtlMod = 0 is rejected with Modbus Exception code 3 (Illegal Data Value)
1772
+ // by some devices (e.g. Fronius GEN24). 0 means "no external control" per the
1773
+ // SunSpec spec, but Fronius firmware requires at least one bit to be set.
1774
+ // Mapping 0 -> CHARGE | DISCHARGE returns the battery to autonomous
1775
+ // self-managed operation on all known SunSpec devices (matches the neutral
1776
+ // reset state used in sunspec-battery-feature-calibrator.ts).
1777
+ const safeMode = controls.storCtlMod === 0
1778
+ ? SunspecStorageControlMode.CHARGE | SunspecStorageControlMode.DISCHARGE
1779
+ : controls.storCtlMod;
1780
+ await this.writeRegisterValue(unitId, baseAddr + 5, safeMode, 'uint16');
1781
+ console.debug(`Set storage control mode to 0x${safeMode.toString(16)}`);
1728
1782
  }
1729
1783
  console.debug('Battery controls written successfully');
1730
1784
  return true;
@@ -1829,7 +1883,16 @@ export class SunspecModbusClient {
1829
1883
  * When a device advertises BOTH encodings, picks the family with more discovered models
1830
1884
  * for this unit (see getPreferredEncoding). Never reads both.
1831
1885
  */
1832
- async readMeterData(unitId) {
1886
+ async readMeterData(unitId, meterModel) {
1887
+ // When a specific meter instance is given (a unit with multiple embedded meters), read
1888
+ // exactly that block — don't let findModel pick the first/only directory entry.
1889
+ if (meterModel) {
1890
+ console.debug(`Using bound meter Model ${meterModel.id} at address ${meterModel.address} (length ${meterModel.length}) on unit ${unitId}`);
1891
+ const floatIds = [211, 212, 213, 214];
1892
+ return floatIds.includes(meterModel.id)
1893
+ ? this.readFloatMeterData(unitId, meterModel, meterModel.id)
1894
+ : this.readIntSfMeterData(unitId, meterModel);
1895
+ }
1833
1896
  const preferred = this.getPreferredEncoding(unitId);
1834
1897
  const tryFloat = async () => {
1835
1898
  const floatIds = [213, 214, 212, 211];
@@ -1987,7 +2050,12 @@ export class SunspecModbusClient {
1987
2050
  }
1988
2051
  }
1989
2052
  /**
1990
- * Read common block data (Model 1)
2053
+ * Read common block data (Model 1).
2054
+ *
2055
+ * Resolves to the unit's FIRST Common block — the leading device's own identity (the
2056
+ * inverter on a SolarEdge SE16K-class unit). When a unit embeds meters, each meter has its
2057
+ * own later Common block; read those with {@link readCommonBlockForModel} so a meter reports
2058
+ * its own manufacturer/model/serial instead of the inverter's (or a sibling meter's).
1991
2059
  */
1992
2060
  async readCommonBlock(unitId) {
1993
2061
  const model = this.findModel(unitId, SunspecModelId.Common);
@@ -1997,6 +2065,31 @@ export class SunspecModbusClient {
1997
2065
  console.debug(`Common block model not found on unit ${unitId}`);
1998
2066
  return null;
1999
2067
  }
2068
+ return this.readCommonBlockAt(unitId, model);
2069
+ }
2070
+ /**
2071
+ * Read the Common (id 1) block that belongs to a specific device model — the Common instance
2072
+ * immediately preceding the model's address. SunSpec lays out each logical device as
2073
+ * `Common → device model(s)`, so a meter's identity is the Common block right before its meter
2074
+ * model. Falls back to the unit's first Common when none precedes (shouldn't happen for a
2075
+ * well-formed map). Used to give each embedded meter its own appliance identity.
2076
+ */
2077
+ async readCommonBlockForModel(unitId, deviceModel) {
2078
+ const preceding = this.findModels(unitId, SunspecModelId.Common)
2079
+ .filter(c => c.address <= deviceModel.address)
2080
+ .sort((a, b) => a.address - b.address)
2081
+ .pop();
2082
+ const model = preceding ?? this.findModel(unitId, SunspecModelId.Common);
2083
+ if (!model) {
2084
+ console.debug(`Common block model not found on unit ${unitId} for model ${deviceModel.id}@${deviceModel.address}`);
2085
+ return null;
2086
+ }
2087
+ return this.readCommonBlockAt(unitId, model);
2088
+ }
2089
+ /**
2090
+ * Decode a given Common (Model 1) block instance into identity fields.
2091
+ */
2092
+ async readCommonBlockAt(unitId, model) {
2000
2093
  console.debug(`Reading Common Block - Model address: ${model.address} (unit ${unitId})`);
2001
2094
  try {
2002
2095
  // Read entire model block in a single Modbus call
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.83";
8
+ export declare const SDK_VERSION = "0.0.85";
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.83';
8
+ export const SDK_VERSION = '0.0.85';
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.83",
3
+ "version": "0.0.85",
4
4
  "description": "enyo Energy Sunspec SDK",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",