@enyo-energy/sunspec-sdk 0.0.81 → 0.0.83

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.
@@ -107,6 +107,15 @@ class BaseSunspecDevice {
107
107
  dataBus;
108
108
  retryManager;
109
109
  consecutiveReconnectFailures = 0;
110
+ /**
111
+ * True once {@link markOffline} has set the appliance state to Offline and
112
+ * it has not yet been re-asserted Connected. Used by
113
+ * {@link reassertConnectedIfRecovered} to heal the shared-socket case: when
114
+ * a sibling device on the same unit wins the reconnect race, this device's
115
+ * readData() sees isConnected() === true and skips tryReconnect(), so it
116
+ * would otherwise stay Offline forever despite reading data fine.
117
+ */
118
+ appliedOffline = false;
110
119
  /**
111
120
  * Prefix used when persisting calibration snapshots via the library's
112
121
  * {@link SnapshotService}. Kept identical to the key the SDK used before
@@ -181,6 +190,7 @@ class BaseSunspecDevice {
181
190
  if (this.applianceId) {
182
191
  await this.applianceManager.updateApplianceState(this.applianceId, enyo_appliance_js_1.EnyoApplianceConnectionType.Connector, enyo_appliance_js_1.EnyoApplianceStateEnum.Connected);
183
192
  }
193
+ this.appliedOffline = false;
184
194
  await this.onConnectionRestored();
185
195
  const postStats = this.sunspecClient.getConnectionStats();
186
196
  console.log(`${this.constructor.name} ${this.applianceId}: Reconnection successful after ${attempt} attempt(s) (opens=${postStats.opens}, closes=${postStats.closes}, openUnits=${postStats.openUnits})`);
@@ -249,12 +259,46 @@ class BaseSunspecDevice {
249
259
  if (this.applianceId) {
250
260
  try {
251
261
  await this.applianceManager.updateApplianceState(this.applianceId, enyo_appliance_js_1.EnyoApplianceConnectionType.Connector, enyo_appliance_js_1.EnyoApplianceStateEnum.Offline);
262
+ this.appliedOffline = true;
252
263
  }
253
264
  catch (error) {
254
265
  console.error(`${this.constructor.name} ${this.applianceId}: Failed to mark appliance offline: ${error}`);
255
266
  }
256
267
  }
257
268
  }
269
+ /**
270
+ * Heal the shared-socket recovery gap. When several devices share one
271
+ * SunspecModbusClient on the same unit (e.g. a Fronius GEN24 inverter and
272
+ * its battery), only the device that detects !isConnected() first runs
273
+ * tryReconnect() — and that is the only path that flips the appliance back
274
+ * to Connected. Siblings then see isConnected() === true on their next
275
+ * readData() and skip tryReconnect() entirely, so without this they would
276
+ * stay Offline forever despite reading data fine.
277
+ *
278
+ * Call this on the success path of readData() (after the
279
+ * markOfflineIfUnhealthy() guard has confirmed the unit is healthy). It is
280
+ * a cheap no-op unless this device was previously marked offline.
281
+ */
282
+ async reassertConnectedIfRecovered() {
283
+ if (!this.appliedOffline || !this.applianceId) {
284
+ return;
285
+ }
286
+ if (!this.sunspecClient.isHealthy(this.unitId)) {
287
+ return;
288
+ }
289
+ console.log(`${this.constructor.name} ${this.applianceId}: connection recovered via shared socket — re-asserting Connected`);
290
+ try {
291
+ await this.applianceManager.updateApplianceState(this.applianceId, enyo_appliance_js_1.EnyoApplianceConnectionType.Connector, enyo_appliance_js_1.EnyoApplianceStateEnum.Connected);
292
+ }
293
+ catch (error) {
294
+ console.error(`${this.constructor.name} ${this.applianceId}: Failed to re-assert Connected: ${error}`);
295
+ return;
296
+ }
297
+ this.appliedOffline = false;
298
+ this.retryManager.reset();
299
+ this.consecutiveReconnectFailures = 0;
300
+ await this.onConnectionRestored();
301
+ }
258
302
  sendCommandAcknowledge(messageId, acknowledgeMessage, answer, rejectionReason) {
259
303
  if (!this.dataBus || !this.applianceId) {
260
304
  return;
@@ -302,6 +346,12 @@ class SunspecInverter extends BaseSunspecDevice {
302
346
  storage;
303
347
  errorState = { activeCodes: [], lastStatus: 'healthy' };
304
348
  snapshotService;
349
+ // Whether we've emitted a status update at least once since (re)connecting.
350
+ // The persisted errorState can diverge from what the core actually shows
351
+ // (e.g. a faulted that was never cleared while our local state reads
352
+ // healthy), so we force one re-assert of the current status on the first
353
+ // successful read of each session to converge the core.
354
+ statusReassertedThisSession = false;
305
355
  constructor(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId = 1, port = 502, baseAddress = 40000, capabilities = [], retryConfig, appliance, useTls) {
306
356
  super(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId, port, baseAddress, retryConfig, appliance, useTls);
307
357
  this.capabilities = capabilities;
@@ -350,9 +400,13 @@ class SunspecInverter extends BaseSunspecDevice {
350
400
  else {
351
401
  try {
352
402
  const existingAppliance = await this.applianceManager.findApplianceById(this.applianceId);
403
+ // Drop any previously core-stored `status` so we don't echo a
404
+ // stale `faulted` back through ApplianceSave — status is driven
405
+ // solely by ApplianceStateUpdateV1 (detectAndEmitStatusTransition).
406
+ const { status: _staleStatus, ...prevMetadata } = existingAppliance?.metadata ?? {};
353
407
  await this.applianceManager.updateAppliance(this.applianceId, {
354
408
  metadata: {
355
- ...existingAppliance?.metadata,
409
+ ...prevMetadata,
356
410
  connectionType: enyo_appliance_js_1.EnyoApplianceConnectionType.Connector,
357
411
  state: enyo_appliance_js_1.EnyoApplianceStateEnum.Connected,
358
412
  modbus: { unitId: this.unitId, baseAddress: this.baseAddress },
@@ -376,6 +430,9 @@ class SunspecInverter extends BaseSunspecDevice {
376
430
  console.log(`MPPT model found for inverter ${this.networkDevice.hostname}`);
377
431
  }
378
432
  await this.loadErrorState();
433
+ // Fresh session: force the next successful read to re-assert the true
434
+ // status so a stale faulted in the core is cleared on (re)start.
435
+ this.statusReassertedThisSession = false;
379
436
  this.startDataBusListening();
380
437
  // Cold-start recovery: if the persisted state says we were stuck in
381
438
  // connection_lost but connect() just succeeded (we read commonBlock,
@@ -420,6 +477,10 @@ class SunspecInverter extends BaseSunspecDevice {
420
477
  if (await this.markOfflineIfUnhealthy()) {
421
478
  return messages;
422
479
  }
480
+ // Healthy read: if a sibling on the same shared socket reconnected us
481
+ // (so our own tryReconnect() never ran), flip our appliance state back
482
+ // to Connected. No-op unless this device is still marked offline.
483
+ await this.reassertConnectedIfRecovered();
423
484
  if (inverterData) {
424
485
  const pvPowerW = dcStrings.reduce((sum, s) => sum + (s.powerW || 0), 0);
425
486
  console.debug(`Got PV Power from DC strings: ${pvPowerW}W`);
@@ -590,16 +651,30 @@ class SunspecInverter extends BaseSunspecDevice {
590
651
  if (!this.applianceId || !this.dataBus)
591
652
  return;
592
653
  const { codes, codeIds } = this.decodeActiveErrors(data);
593
- const recoveringFromConnectionLoss = this.errorState.lastStatus === 'connection_lost';
594
- if (!recoveringFromConnectionLoss && !this.hasErrorSetChanged(codeIds)) {
654
+ // Status is driven by BOTH the decoded event-register error bits AND the
655
+ // SunSpec operating state. Only operatingState 7 (FAULT) is a genuine
656
+ // fault; MPPT (4), SLEEPING (2) and the other states are normal. This
657
+ // is re-evaluated every poll so a producing inverter (operatingState=4,
658
+ // no error bits) reports Healthy even if it was previously faulted.
659
+ const operatingFault = data.operatingState === sunspec_interfaces_js_1.SunspecInverterOperatingState.FAULT;
660
+ const newStatus = (codeIds.length > 0 || operatingFault)
661
+ ? enyo_appliance_js_1.EnyoApplianceStatusEnum.Faulted
662
+ : enyo_appliance_js_1.EnyoApplianceStatusEnum.Healthy;
663
+ const newLastStatus = newStatus === enyo_appliance_js_1.EnyoApplianceStatusEnum.Healthy ? 'healthy' : 'faulted';
664
+ // Emit when the effective status changed (covers faulted/connection_lost
665
+ // -> healthy convergence), when the active error-code set changed, or
666
+ // once per session to re-assert the true status to the core (clears a
667
+ // stale faulted that diverged from our local state). Otherwise stay
668
+ // quiet so we don't republish the same status every 10s poll.
669
+ const statusChanged = newLastStatus !== this.errorState.lastStatus;
670
+ const mustReassert = !this.statusReassertedThisSession;
671
+ if (!statusChanged && !this.hasErrorSetChanged(codeIds) && !mustReassert) {
595
672
  return;
596
673
  }
597
- const newStatus = codeIds.length === 0
598
- ? enyo_appliance_js_1.EnyoApplianceStatusEnum.Healthy
599
- : enyo_appliance_js_1.EnyoApplianceStatusEnum.Faulted;
600
674
  const message = this.buildStatusMessage(newStatus, codes, timestamp);
601
- console.log(`Inverter ${this.applianceId}: status transition -> ${newStatus} (codes=[${codeIds.join(', ')}])`);
675
+ console.log(`Inverter ${this.applianceId}: status transition -> ${newStatus} (codes=[${codeIds.join(', ')}], operatingState=${data.operatingState})`);
602
676
  this.dataBus.sendMessage([message]);
677
+ this.statusReassertedThisSession = true;
603
678
  this.errorState = {
604
679
  evt1: data.events,
605
680
  evt2: data.events2,
@@ -608,7 +683,7 @@ class SunspecInverter extends BaseSunspecDevice {
608
683
  evtVnd3: data.vendorEvents3,
609
684
  evtVnd4: data.vendorEvents4,
610
685
  activeCodes: codeIds,
611
- lastStatus: newStatus === enyo_appliance_js_1.EnyoApplianceStatusEnum.Healthy ? 'healthy' : 'faulted',
686
+ lastStatus: newLastStatus,
612
687
  };
613
688
  await this.persistErrorState();
614
689
  }
@@ -1183,6 +1258,10 @@ class SunspecBattery extends BaseSunspecDevice {
1183
1258
  if (await this.markOfflineIfUnhealthy()) {
1184
1259
  return messages;
1185
1260
  }
1261
+ // Healthy read: if a sibling on the same shared socket reconnected us
1262
+ // (so our own tryReconnect() never ran), flip our appliance state back
1263
+ // to Connected. No-op unless this device is still marked offline.
1264
+ await this.reassertConnectedIfRecovered();
1186
1265
  if (batteryData) {
1187
1266
  // See `selectLiveBatteryPowerW` for the source-preference rationale.
1188
1267
  const batteryPowerW = selectLiveBatteryPowerW(mpptBatteryPowerW, batteryData);
@@ -1696,6 +1775,10 @@ class SunspecMeter extends BaseSunspecDevice {
1696
1775
  if (await this.markOfflineIfUnhealthy()) {
1697
1776
  return messages;
1698
1777
  }
1778
+ // Healthy read: if a sibling on the same shared socket reconnected us
1779
+ // (so our own tryReconnect() never ran), flip our appliance state back
1780
+ // to Connected. No-op unless this device is still marked offline.
1781
+ await this.reassertConnectedIfRecovered();
1699
1782
  if (meterData) {
1700
1783
  const meterMessage = {
1701
1784
  id: (0, node_crypto_1.randomUUID)(),
@@ -27,6 +27,15 @@ export declare abstract class BaseSunspecDevice {
27
27
  protected dataBus?: EnergyAppDataBus;
28
28
  protected retryManager: ConnectionRetryManager;
29
29
  protected consecutiveReconnectFailures: number;
30
+ /**
31
+ * True once {@link markOffline} has set the appliance state to Offline and
32
+ * it has not yet been re-asserted Connected. Used by
33
+ * {@link reassertConnectedIfRecovered} to heal the shared-socket case: when
34
+ * a sibling device on the same unit wins the reconnect race, this device's
35
+ * readData() sees isConnected() === true and skips tryReconnect(), so it
36
+ * would otherwise stay Offline forever despite reading data fine.
37
+ */
38
+ private appliedOffline;
30
39
  /**
31
40
  * Prefix used when persisting calibration snapshots via the library's
32
41
  * {@link SnapshotService}. Kept identical to the key the SDK used before
@@ -90,6 +99,20 @@ export declare abstract class BaseSunspecDevice {
90
99
  * connection after the backoff interval. Then update appliance state.
91
100
  */
92
101
  protected markOffline(): Promise<void>;
102
+ /**
103
+ * Heal the shared-socket recovery gap. When several devices share one
104
+ * SunspecModbusClient on the same unit (e.g. a Fronius GEN24 inverter and
105
+ * its battery), only the device that detects !isConnected() first runs
106
+ * tryReconnect() — and that is the only path that flips the appliance back
107
+ * to Connected. Siblings then see isConnected() === true on their next
108
+ * readData() and skip tryReconnect() entirely, so without this they would
109
+ * stay Offline forever despite reading data fine.
110
+ *
111
+ * Call this on the success path of readData() (after the
112
+ * markOfflineIfUnhealthy() guard has confirmed the unit is healthy). It is
113
+ * a cheap no-op unless this device was previously marked offline.
114
+ */
115
+ protected reassertConnectedIfRecovered(): Promise<void>;
93
116
  protected sendCommandAcknowledge(messageId: string, acknowledgeMessage: EnyoDataBusMessageEnum | string, answer: EnyoCommandAcknowledgeAnswerEnum, rejectionReason?: string): void;
94
117
  /**
95
118
  * Build a typed {@link SnapshotService} bound to this appliance's calibration storage,
@@ -108,6 +131,7 @@ export declare class SunspecInverter extends BaseSunspecDevice {
108
131
  private storage?;
109
132
  private errorState;
110
133
  private snapshotService?;
134
+ private statusReassertedThisSession;
111
135
  constructor(energyApp: EnergyApp, name: EnyoApplianceName[], networkDevice: EnyoNetworkDevice, sunspecClient: SunspecModbusClient, applianceManager: ApplianceManager, unitId?: number, port?: number, baseAddress?: number, capabilities?: SunspecInverterCapability[], retryConfig?: IRetryConfig, appliance?: EnyoAppliance, useTls?: boolean);
112
136
  connect(): Promise<void>;
113
137
  disconnect(): Promise<void>;
@@ -3,7 +3,7 @@
3
3
  * SunSpec block interfaces with block numbers
4
4
  */
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.SUNSPEC_CONTROLLABLE_FEATURES = exports.SunspecMeterCapability = exports.SunspecBatteryFeatureModeKind = exports.SunspecBatteryCapability = exports.SunspecInverterCapability = exports.SunspecStorageMode = exports.SunspecChargeSource = exports.SunspecVArPctMode = exports.SunspecEnableControl = exports.SunspecConnectionControl = exports.SunspecStorageControlMode = exports.SunspecBatteryEvent1 = exports.SunspecBatteryBankState = exports.SunspecBatteryType = exports.SunspecBatteryControlMode = exports.SunspecBatteryChargeState = exports.SunspecMPPTOperatingState = exports.SUNSPEC_CONNECTION_LOST_CODE = exports.SunspecInverterEvent1 = exports.SunspecModelId = exports.DEFAULT_RETRY_CONFIG = void 0;
6
+ exports.SUNSPEC_CONTROLLABLE_FEATURES = exports.SunspecMeterCapability = exports.SunspecBatteryFeatureModeKind = exports.SunspecBatteryCapability = exports.SunspecInverterCapability = exports.SunspecStorageMode = exports.SunspecChargeSource = exports.SunspecVArPctMode = exports.SunspecEnableControl = exports.SunspecConnectionControl = exports.SunspecStorageControlMode = exports.SunspecBatteryEvent1 = exports.SunspecBatteryBankState = exports.SunspecBatteryType = exports.SunspecBatteryControlMode = exports.SunspecBatteryChargeState = exports.SunspecInverterOperatingState = exports.SunspecMPPTOperatingState = exports.SUNSPEC_CONNECTION_LOST_CODE = exports.SunspecInverterEvent1 = exports.SunspecModelId = exports.DEFAULT_RETRY_CONFIG = void 0;
7
7
  const enyo_battery_appliance_js_1 = require("@enyo-energy/energy-app-sdk/dist/types/enyo-battery-appliance.js");
8
8
  exports.DEFAULT_RETRY_CONFIG = {
9
9
  phases: [
@@ -87,6 +87,22 @@ var SunspecMPPTOperatingState;
87
87
  SunspecMPPTOperatingState[SunspecMPPTOperatingState["TEST"] = 9] = "TEST";
88
88
  SunspecMPPTOperatingState[SunspecMPPTOperatingState["RESERVED_10"] = 10] = "RESERVED_10";
89
89
  })(SunspecMPPTOperatingState || (exports.SunspecMPPTOperatingState = SunspecMPPTOperatingState = {}));
90
+ /**
91
+ * Inverter Operating State values (SunSpec `St`, register offset 38) for
92
+ * models 101/103/111/112/113. Mirrors the SunSpec spec. Only `FAULT` is a
93
+ * genuine fault; `SLEEPING` and `MPPT` are normal production/standby states.
94
+ */
95
+ var SunspecInverterOperatingState;
96
+ (function (SunspecInverterOperatingState) {
97
+ SunspecInverterOperatingState[SunspecInverterOperatingState["OFF"] = 1] = "OFF";
98
+ SunspecInverterOperatingState[SunspecInverterOperatingState["SLEEPING"] = 2] = "SLEEPING";
99
+ SunspecInverterOperatingState[SunspecInverterOperatingState["STARTING"] = 3] = "STARTING";
100
+ SunspecInverterOperatingState[SunspecInverterOperatingState["MPPT"] = 4] = "MPPT";
101
+ SunspecInverterOperatingState[SunspecInverterOperatingState["THROTTLED"] = 5] = "THROTTLED";
102
+ SunspecInverterOperatingState[SunspecInverterOperatingState["SHUTTING_DOWN"] = 6] = "SHUTTING_DOWN";
103
+ SunspecInverterOperatingState[SunspecInverterOperatingState["FAULT"] = 7] = "FAULT";
104
+ SunspecInverterOperatingState[SunspecInverterOperatingState["STANDBY"] = 8] = "STANDBY";
105
+ })(SunspecInverterOperatingState || (exports.SunspecInverterOperatingState = SunspecInverterOperatingState = {}));
90
106
  /**
91
107
  * Battery Charge State values for Model 124
92
108
  */
@@ -222,6 +222,21 @@ export declare enum SunspecMPPTOperatingState {
222
222
  TEST = 9,
223
223
  RESERVED_10 = 10
224
224
  }
225
+ /**
226
+ * Inverter Operating State values (SunSpec `St`, register offset 38) for
227
+ * models 101/103/111/112/113. Mirrors the SunSpec spec. Only `FAULT` is a
228
+ * genuine fault; `SLEEPING` and `MPPT` are normal production/standby states.
229
+ */
230
+ export declare enum SunspecInverterOperatingState {
231
+ OFF = 1,
232
+ SLEEPING = 2,
233
+ STARTING = 3,
234
+ MPPT = 4,// Maximum Power Point Tracking active (producing)
235
+ THROTTLED = 5,// Power output is being limited
236
+ SHUTTING_DOWN = 6,
237
+ FAULT = 7,
238
+ STANDBY = 8
239
+ }
225
240
  /**
226
241
  * Battery Charge State values for Model 124
227
242
  */
@@ -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.81';
12
+ exports.SDK_VERSION = '0.0.83';
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.81";
8
+ export declare const SDK_VERSION = "0.0.83";
9
9
  /**
10
10
  * Gets the current SDK version.
11
11
  * @returns The semantic version string of the SDK
@@ -27,6 +27,15 @@ export declare abstract class BaseSunspecDevice {
27
27
  protected dataBus?: EnergyAppDataBus;
28
28
  protected retryManager: ConnectionRetryManager;
29
29
  protected consecutiveReconnectFailures: number;
30
+ /**
31
+ * True once {@link markOffline} has set the appliance state to Offline and
32
+ * it has not yet been re-asserted Connected. Used by
33
+ * {@link reassertConnectedIfRecovered} to heal the shared-socket case: when
34
+ * a sibling device on the same unit wins the reconnect race, this device's
35
+ * readData() sees isConnected() === true and skips tryReconnect(), so it
36
+ * would otherwise stay Offline forever despite reading data fine.
37
+ */
38
+ private appliedOffline;
30
39
  /**
31
40
  * Prefix used when persisting calibration snapshots via the library's
32
41
  * {@link SnapshotService}. Kept identical to the key the SDK used before
@@ -90,6 +99,20 @@ export declare abstract class BaseSunspecDevice {
90
99
  * connection after the backoff interval. Then update appliance state.
91
100
  */
92
101
  protected markOffline(): Promise<void>;
102
+ /**
103
+ * Heal the shared-socket recovery gap. When several devices share one
104
+ * SunspecModbusClient on the same unit (e.g. a Fronius GEN24 inverter and
105
+ * its battery), only the device that detects !isConnected() first runs
106
+ * tryReconnect() — and that is the only path that flips the appliance back
107
+ * to Connected. Siblings then see isConnected() === true on their next
108
+ * readData() and skip tryReconnect() entirely, so without this they would
109
+ * stay Offline forever despite reading data fine.
110
+ *
111
+ * Call this on the success path of readData() (after the
112
+ * markOfflineIfUnhealthy() guard has confirmed the unit is healthy). It is
113
+ * a cheap no-op unless this device was previously marked offline.
114
+ */
115
+ protected reassertConnectedIfRecovered(): Promise<void>;
93
116
  protected sendCommandAcknowledge(messageId: string, acknowledgeMessage: EnyoDataBusMessageEnum | string, answer: EnyoCommandAcknowledgeAnswerEnum, rejectionReason?: string): void;
94
117
  /**
95
118
  * Build a typed {@link SnapshotService} bound to this appliance's calibration storage,
@@ -108,6 +131,7 @@ export declare class SunspecInverter extends BaseSunspecDevice {
108
131
  private storage?;
109
132
  private errorState;
110
133
  private snapshotService?;
134
+ private statusReassertedThisSession;
111
135
  constructor(energyApp: EnergyApp, name: EnyoApplianceName[], networkDevice: EnyoNetworkDevice, sunspecClient: SunspecModbusClient, applianceManager: ApplianceManager, unitId?: number, port?: number, baseAddress?: number, capabilities?: SunspecInverterCapability[], retryConfig?: IRetryConfig, appliance?: EnyoAppliance, useTls?: boolean);
112
136
  connect(): Promise<void>;
113
137
  disconnect(): Promise<void>;
@@ -1,4 +1,4 @@
1
- import { SunspecBatteryChargeState, SunspecBatteryFeatureModeKind, SunspecEnableControl, SunspecInverterCapability, SunspecInverterEvent1, SunspecModelId, SunspecMPPTOperatingState, SunspecStorageMode, SUNSPEC_CONNECTION_LOST_CODE } from "./sunspec-interfaces.js";
1
+ import { SunspecBatteryChargeState, SunspecBatteryFeatureModeKind, SunspecEnableControl, SunspecInverterCapability, SunspecInverterEvent1, SunspecInverterOperatingState, SunspecModelId, SunspecMPPTOperatingState, SunspecStorageMode, SUNSPEC_CONNECTION_LOST_CODE } from "./sunspec-interfaces.js";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { EnyoApplianceConnectionType, EnyoApplianceStateEnum, EnyoApplianceStatusEnum, EnyoApplianceTopologyFeatureEnum, EnyoApplianceTypeEnum } from "@enyo-energy/energy-app-sdk/dist/types/enyo-appliance.js";
4
4
  import { ConnectionRetryManager } from "./connection-retry-manager.js";
@@ -101,6 +101,15 @@ export class BaseSunspecDevice {
101
101
  dataBus;
102
102
  retryManager;
103
103
  consecutiveReconnectFailures = 0;
104
+ /**
105
+ * True once {@link markOffline} has set the appliance state to Offline and
106
+ * it has not yet been re-asserted Connected. Used by
107
+ * {@link reassertConnectedIfRecovered} to heal the shared-socket case: when
108
+ * a sibling device on the same unit wins the reconnect race, this device's
109
+ * readData() sees isConnected() === true and skips tryReconnect(), so it
110
+ * would otherwise stay Offline forever despite reading data fine.
111
+ */
112
+ appliedOffline = false;
104
113
  /**
105
114
  * Prefix used when persisting calibration snapshots via the library's
106
115
  * {@link SnapshotService}. Kept identical to the key the SDK used before
@@ -175,6 +184,7 @@ export class BaseSunspecDevice {
175
184
  if (this.applianceId) {
176
185
  await this.applianceManager.updateApplianceState(this.applianceId, EnyoApplianceConnectionType.Connector, EnyoApplianceStateEnum.Connected);
177
186
  }
187
+ this.appliedOffline = false;
178
188
  await this.onConnectionRestored();
179
189
  const postStats = this.sunspecClient.getConnectionStats();
180
190
  console.log(`${this.constructor.name} ${this.applianceId}: Reconnection successful after ${attempt} attempt(s) (opens=${postStats.opens}, closes=${postStats.closes}, openUnits=${postStats.openUnits})`);
@@ -243,12 +253,46 @@ export class BaseSunspecDevice {
243
253
  if (this.applianceId) {
244
254
  try {
245
255
  await this.applianceManager.updateApplianceState(this.applianceId, EnyoApplianceConnectionType.Connector, EnyoApplianceStateEnum.Offline);
256
+ this.appliedOffline = true;
246
257
  }
247
258
  catch (error) {
248
259
  console.error(`${this.constructor.name} ${this.applianceId}: Failed to mark appliance offline: ${error}`);
249
260
  }
250
261
  }
251
262
  }
263
+ /**
264
+ * Heal the shared-socket recovery gap. When several devices share one
265
+ * SunspecModbusClient on the same unit (e.g. a Fronius GEN24 inverter and
266
+ * its battery), only the device that detects !isConnected() first runs
267
+ * tryReconnect() — and that is the only path that flips the appliance back
268
+ * to Connected. Siblings then see isConnected() === true on their next
269
+ * readData() and skip tryReconnect() entirely, so without this they would
270
+ * stay Offline forever despite reading data fine.
271
+ *
272
+ * Call this on the success path of readData() (after the
273
+ * markOfflineIfUnhealthy() guard has confirmed the unit is healthy). It is
274
+ * a cheap no-op unless this device was previously marked offline.
275
+ */
276
+ async reassertConnectedIfRecovered() {
277
+ if (!this.appliedOffline || !this.applianceId) {
278
+ return;
279
+ }
280
+ if (!this.sunspecClient.isHealthy(this.unitId)) {
281
+ return;
282
+ }
283
+ console.log(`${this.constructor.name} ${this.applianceId}: connection recovered via shared socket — re-asserting Connected`);
284
+ try {
285
+ await this.applianceManager.updateApplianceState(this.applianceId, EnyoApplianceConnectionType.Connector, EnyoApplianceStateEnum.Connected);
286
+ }
287
+ catch (error) {
288
+ console.error(`${this.constructor.name} ${this.applianceId}: Failed to re-assert Connected: ${error}`);
289
+ return;
290
+ }
291
+ this.appliedOffline = false;
292
+ this.retryManager.reset();
293
+ this.consecutiveReconnectFailures = 0;
294
+ await this.onConnectionRestored();
295
+ }
252
296
  sendCommandAcknowledge(messageId, acknowledgeMessage, answer, rejectionReason) {
253
297
  if (!this.dataBus || !this.applianceId) {
254
298
  return;
@@ -295,6 +339,12 @@ export class SunspecInverter extends BaseSunspecDevice {
295
339
  storage;
296
340
  errorState = { activeCodes: [], lastStatus: 'healthy' };
297
341
  snapshotService;
342
+ // Whether we've emitted a status update at least once since (re)connecting.
343
+ // The persisted errorState can diverge from what the core actually shows
344
+ // (e.g. a faulted that was never cleared while our local state reads
345
+ // healthy), so we force one re-assert of the current status on the first
346
+ // successful read of each session to converge the core.
347
+ statusReassertedThisSession = false;
298
348
  constructor(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId = 1, port = 502, baseAddress = 40000, capabilities = [], retryConfig, appliance, useTls) {
299
349
  super(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId, port, baseAddress, retryConfig, appliance, useTls);
300
350
  this.capabilities = capabilities;
@@ -343,9 +393,13 @@ export class SunspecInverter extends BaseSunspecDevice {
343
393
  else {
344
394
  try {
345
395
  const existingAppliance = await this.applianceManager.findApplianceById(this.applianceId);
396
+ // Drop any previously core-stored `status` so we don't echo a
397
+ // stale `faulted` back through ApplianceSave — status is driven
398
+ // solely by ApplianceStateUpdateV1 (detectAndEmitStatusTransition).
399
+ const { status: _staleStatus, ...prevMetadata } = existingAppliance?.metadata ?? {};
346
400
  await this.applianceManager.updateAppliance(this.applianceId, {
347
401
  metadata: {
348
- ...existingAppliance?.metadata,
402
+ ...prevMetadata,
349
403
  connectionType: EnyoApplianceConnectionType.Connector,
350
404
  state: EnyoApplianceStateEnum.Connected,
351
405
  modbus: { unitId: this.unitId, baseAddress: this.baseAddress },
@@ -369,6 +423,9 @@ export class SunspecInverter extends BaseSunspecDevice {
369
423
  console.log(`MPPT model found for inverter ${this.networkDevice.hostname}`);
370
424
  }
371
425
  await this.loadErrorState();
426
+ // Fresh session: force the next successful read to re-assert the true
427
+ // status so a stale faulted in the core is cleared on (re)start.
428
+ this.statusReassertedThisSession = false;
372
429
  this.startDataBusListening();
373
430
  // Cold-start recovery: if the persisted state says we were stuck in
374
431
  // connection_lost but connect() just succeeded (we read commonBlock,
@@ -413,6 +470,10 @@ export class SunspecInverter extends BaseSunspecDevice {
413
470
  if (await this.markOfflineIfUnhealthy()) {
414
471
  return messages;
415
472
  }
473
+ // Healthy read: if a sibling on the same shared socket reconnected us
474
+ // (so our own tryReconnect() never ran), flip our appliance state back
475
+ // to Connected. No-op unless this device is still marked offline.
476
+ await this.reassertConnectedIfRecovered();
416
477
  if (inverterData) {
417
478
  const pvPowerW = dcStrings.reduce((sum, s) => sum + (s.powerW || 0), 0);
418
479
  console.debug(`Got PV Power from DC strings: ${pvPowerW}W`);
@@ -583,16 +644,30 @@ export class SunspecInverter extends BaseSunspecDevice {
583
644
  if (!this.applianceId || !this.dataBus)
584
645
  return;
585
646
  const { codes, codeIds } = this.decodeActiveErrors(data);
586
- const recoveringFromConnectionLoss = this.errorState.lastStatus === 'connection_lost';
587
- if (!recoveringFromConnectionLoss && !this.hasErrorSetChanged(codeIds)) {
647
+ // Status is driven by BOTH the decoded event-register error bits AND the
648
+ // SunSpec operating state. Only operatingState 7 (FAULT) is a genuine
649
+ // fault; MPPT (4), SLEEPING (2) and the other states are normal. This
650
+ // is re-evaluated every poll so a producing inverter (operatingState=4,
651
+ // no error bits) reports Healthy even if it was previously faulted.
652
+ const operatingFault = data.operatingState === SunspecInverterOperatingState.FAULT;
653
+ const newStatus = (codeIds.length > 0 || operatingFault)
654
+ ? EnyoApplianceStatusEnum.Faulted
655
+ : EnyoApplianceStatusEnum.Healthy;
656
+ const newLastStatus = newStatus === EnyoApplianceStatusEnum.Healthy ? 'healthy' : 'faulted';
657
+ // Emit when the effective status changed (covers faulted/connection_lost
658
+ // -> healthy convergence), when the active error-code set changed, or
659
+ // once per session to re-assert the true status to the core (clears a
660
+ // stale faulted that diverged from our local state). Otherwise stay
661
+ // quiet so we don't republish the same status every 10s poll.
662
+ const statusChanged = newLastStatus !== this.errorState.lastStatus;
663
+ const mustReassert = !this.statusReassertedThisSession;
664
+ if (!statusChanged && !this.hasErrorSetChanged(codeIds) && !mustReassert) {
588
665
  return;
589
666
  }
590
- const newStatus = codeIds.length === 0
591
- ? EnyoApplianceStatusEnum.Healthy
592
- : EnyoApplianceStatusEnum.Faulted;
593
667
  const message = this.buildStatusMessage(newStatus, codes, timestamp);
594
- console.log(`Inverter ${this.applianceId}: status transition -> ${newStatus} (codes=[${codeIds.join(', ')}])`);
668
+ console.log(`Inverter ${this.applianceId}: status transition -> ${newStatus} (codes=[${codeIds.join(', ')}], operatingState=${data.operatingState})`);
595
669
  this.dataBus.sendMessage([message]);
670
+ this.statusReassertedThisSession = true;
596
671
  this.errorState = {
597
672
  evt1: data.events,
598
673
  evt2: data.events2,
@@ -601,7 +676,7 @@ export class SunspecInverter extends BaseSunspecDevice {
601
676
  evtVnd3: data.vendorEvents3,
602
677
  evtVnd4: data.vendorEvents4,
603
678
  activeCodes: codeIds,
604
- lastStatus: newStatus === EnyoApplianceStatusEnum.Healthy ? 'healthy' : 'faulted',
679
+ lastStatus: newLastStatus,
605
680
  };
606
681
  await this.persistErrorState();
607
682
  }
@@ -1175,6 +1250,10 @@ export class SunspecBattery extends BaseSunspecDevice {
1175
1250
  if (await this.markOfflineIfUnhealthy()) {
1176
1251
  return messages;
1177
1252
  }
1253
+ // Healthy read: if a sibling on the same shared socket reconnected us
1254
+ // (so our own tryReconnect() never ran), flip our appliance state back
1255
+ // to Connected. No-op unless this device is still marked offline.
1256
+ await this.reassertConnectedIfRecovered();
1178
1257
  if (batteryData) {
1179
1258
  // See `selectLiveBatteryPowerW` for the source-preference rationale.
1180
1259
  const batteryPowerW = selectLiveBatteryPowerW(mpptBatteryPowerW, batteryData);
@@ -1687,6 +1766,10 @@ export class SunspecMeter extends BaseSunspecDevice {
1687
1766
  if (await this.markOfflineIfUnhealthy()) {
1688
1767
  return messages;
1689
1768
  }
1769
+ // Healthy read: if a sibling on the same shared socket reconnected us
1770
+ // (so our own tryReconnect() never ran), flip our appliance state back
1771
+ // to Connected. No-op unless this device is still marked offline.
1772
+ await this.reassertConnectedIfRecovered();
1690
1773
  if (meterData) {
1691
1774
  const meterMessage = {
1692
1775
  id: randomUUID(),
@@ -222,6 +222,21 @@ export declare enum SunspecMPPTOperatingState {
222
222
  TEST = 9,
223
223
  RESERVED_10 = 10
224
224
  }
225
+ /**
226
+ * Inverter Operating State values (SunSpec `St`, register offset 38) for
227
+ * models 101/103/111/112/113. Mirrors the SunSpec spec. Only `FAULT` is a
228
+ * genuine fault; `SLEEPING` and `MPPT` are normal production/standby states.
229
+ */
230
+ export declare enum SunspecInverterOperatingState {
231
+ OFF = 1,
232
+ SLEEPING = 2,
233
+ STARTING = 3,
234
+ MPPT = 4,// Maximum Power Point Tracking active (producing)
235
+ THROTTLED = 5,// Power output is being limited
236
+ SHUTTING_DOWN = 6,
237
+ FAULT = 7,
238
+ STANDBY = 8
239
+ }
225
240
  /**
226
241
  * Battery Charge State values for Model 124
227
242
  */
@@ -84,6 +84,22 @@ export var SunspecMPPTOperatingState;
84
84
  SunspecMPPTOperatingState[SunspecMPPTOperatingState["TEST"] = 9] = "TEST";
85
85
  SunspecMPPTOperatingState[SunspecMPPTOperatingState["RESERVED_10"] = 10] = "RESERVED_10";
86
86
  })(SunspecMPPTOperatingState || (SunspecMPPTOperatingState = {}));
87
+ /**
88
+ * Inverter Operating State values (SunSpec `St`, register offset 38) for
89
+ * models 101/103/111/112/113. Mirrors the SunSpec spec. Only `FAULT` is a
90
+ * genuine fault; `SLEEPING` and `MPPT` are normal production/standby states.
91
+ */
92
+ export var SunspecInverterOperatingState;
93
+ (function (SunspecInverterOperatingState) {
94
+ SunspecInverterOperatingState[SunspecInverterOperatingState["OFF"] = 1] = "OFF";
95
+ SunspecInverterOperatingState[SunspecInverterOperatingState["SLEEPING"] = 2] = "SLEEPING";
96
+ SunspecInverterOperatingState[SunspecInverterOperatingState["STARTING"] = 3] = "STARTING";
97
+ SunspecInverterOperatingState[SunspecInverterOperatingState["MPPT"] = 4] = "MPPT";
98
+ SunspecInverterOperatingState[SunspecInverterOperatingState["THROTTLED"] = 5] = "THROTTLED";
99
+ SunspecInverterOperatingState[SunspecInverterOperatingState["SHUTTING_DOWN"] = 6] = "SHUTTING_DOWN";
100
+ SunspecInverterOperatingState[SunspecInverterOperatingState["FAULT"] = 7] = "FAULT";
101
+ SunspecInverterOperatingState[SunspecInverterOperatingState["STANDBY"] = 8] = "STANDBY";
102
+ })(SunspecInverterOperatingState || (SunspecInverterOperatingState = {}));
87
103
  /**
88
104
  * Battery Charge State values for Model 124
89
105
  */
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.81";
8
+ export declare const SDK_VERSION = "0.0.83";
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.81';
8
+ export const SDK_VERSION = '0.0.83';
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.81",
3
+ "version": "0.0.83",
4
4
  "description": "enyo Energy Sunspec SDK",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -41,7 +41,7 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "@enyo-energy/appliance-calibration": "0.0.2",
44
- "@enyo-energy/energy-app-sdk": "^0.0.159"
44
+ "@enyo-energy/energy-app-sdk": "^0.0.160"
45
45
  },
46
46
  "volta": {
47
47
  "node": "22.17.0"