@enyo-energy/sunspec-sdk 0.0.48 → 0.0.49

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.
@@ -9,6 +9,77 @@ const enyo_data_bus_value_js_1 = require("@enyo-energy/energy-app-sdk/dist/types
9
9
  const enyo_source_enum_js_1 = require("@enyo-energy/energy-app-sdk/dist/types/enyo-source.enum.js");
10
10
  const enyo_meter_appliance_js_1 = require("@enyo-energy/energy-app-sdk/dist/types/enyo-meter-appliance.js");
11
11
  const enyo_battery_appliance_js_1 = require("@enyo-energy/energy-app-sdk/dist/types/enyo-battery-appliance.js");
12
+ /**
13
+ * Translated messages (en, de) for the standard SunSpec inverter Evt1 bits.
14
+ * Consumers can render these directly to end users; if no entry matches a
15
+ * locale, the SDK contract is to fall back to the machine-readable `code`.
16
+ */
17
+ const SUNSPEC_INVERTER_EVENT1_MESSAGES = {
18
+ [sunspec_interfaces_js_1.SunspecInverterEvent1.GROUND_FAULT]: [
19
+ { language: 'en', message: 'Ground fault detected' },
20
+ { language: 'de', message: 'Erdschluss erkannt' },
21
+ ],
22
+ [sunspec_interfaces_js_1.SunspecInverterEvent1.DC_OVER_VOLT]: [
23
+ { language: 'en', message: 'DC over-voltage' },
24
+ { language: 'de', message: 'DC-Überspannung' },
25
+ ],
26
+ [sunspec_interfaces_js_1.SunspecInverterEvent1.AC_DISCONNECT]: [
27
+ { language: 'en', message: 'AC disconnect open' },
28
+ { language: 'de', message: 'AC-Trennschalter offen' },
29
+ ],
30
+ [sunspec_interfaces_js_1.SunspecInverterEvent1.DC_DISCONNECT]: [
31
+ { language: 'en', message: 'DC disconnect open' },
32
+ { language: 'de', message: 'DC-Trennschalter offen' },
33
+ ],
34
+ [sunspec_interfaces_js_1.SunspecInverterEvent1.GRID_DISCONNECT]: [
35
+ { language: 'en', message: 'Grid disconnected' },
36
+ { language: 'de', message: 'Netz getrennt' },
37
+ ],
38
+ [sunspec_interfaces_js_1.SunspecInverterEvent1.CABINET_OPEN]: [
39
+ { language: 'en', message: 'Cabinet open' },
40
+ { language: 'de', message: 'Gehäuse offen' },
41
+ ],
42
+ [sunspec_interfaces_js_1.SunspecInverterEvent1.MANUAL_SHUTDOWN]: [
43
+ { language: 'en', message: 'Manual shutdown' },
44
+ { language: 'de', message: 'Manuelle Abschaltung' },
45
+ ],
46
+ [sunspec_interfaces_js_1.SunspecInverterEvent1.OVER_TEMP]: [
47
+ { language: 'en', message: 'Over temperature' },
48
+ { language: 'de', message: 'Übertemperatur' },
49
+ ],
50
+ [sunspec_interfaces_js_1.SunspecInverterEvent1.OVER_FREQUENCY]: [
51
+ { language: 'en', message: 'AC frequency above limit' },
52
+ { language: 'de', message: 'AC-Frequenz zu hoch' },
53
+ ],
54
+ [sunspec_interfaces_js_1.SunspecInverterEvent1.UNDER_FREQUENCY]: [
55
+ { language: 'en', message: 'AC frequency below limit' },
56
+ { language: 'de', message: 'AC-Frequenz zu niedrig' },
57
+ ],
58
+ [sunspec_interfaces_js_1.SunspecInverterEvent1.AC_OVER_VOLT]: [
59
+ { language: 'en', message: 'AC over-voltage' },
60
+ { language: 'de', message: 'AC-Überspannung' },
61
+ ],
62
+ [sunspec_interfaces_js_1.SunspecInverterEvent1.AC_UNDER_VOLT]: [
63
+ { language: 'en', message: 'AC under-voltage' },
64
+ { language: 'de', message: 'AC-Unterspannung' },
65
+ ],
66
+ [sunspec_interfaces_js_1.SunspecInverterEvent1.BLOWN_STRING_FUSE]: [
67
+ { language: 'en', message: 'Blown string fuse' },
68
+ { language: 'de', message: 'Strangsicherung defekt' },
69
+ ],
70
+ [sunspec_interfaces_js_1.SunspecInverterEvent1.UNDER_TEMP]: [
71
+ { language: 'en', message: 'Under temperature' },
72
+ { language: 'de', message: 'Untertemperatur' },
73
+ ],
74
+ [sunspec_interfaces_js_1.SunspecInverterEvent1.MEMORY_LOSS]: [
75
+ { language: 'en', message: 'Memory loss' },
76
+ { language: 'de', message: 'Speicherverlust' },
77
+ ],
78
+ [sunspec_interfaces_js_1.SunspecInverterEvent1.HW_TEST_FAILURE]: [
79
+ { language: 'en', message: 'Hardware self-test failed' },
80
+ { language: 'de', message: 'Hardware-Selbsttest fehlgeschlagen' },
81
+ ],
82
+ };
12
83
  /**
13
84
  * Base abstract class for all Sunspec devices
14
85
  */
@@ -26,6 +97,7 @@ class BaseSunspecDevice {
26
97
  dataBusListenerId;
27
98
  dataBus;
28
99
  retryManager;
100
+ consecutiveReconnectFailures = 0;
29
101
  constructor(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId = 1, port = 502, baseAddress = 40000, retryConfig, appliance) {
30
102
  this.energyApp = energyApp;
31
103
  this.name = name;
@@ -84,6 +156,7 @@ class BaseSunspecDevice {
84
156
  // Re-discover models after reconnect
85
157
  await this.sunspecClient.discoverModels(this.baseAddress);
86
158
  this.retryManager.reset();
159
+ this.consecutiveReconnectFailures = 0;
87
160
  // Update appliance state to Connected
88
161
  if (this.applianceId) {
89
162
  await this.applianceManager.updateApplianceState(this.applianceId, enyo_appliance_js_1.EnyoApplianceConnectionType.Connector, enyo_appliance_js_1.EnyoApplianceStateEnum.Connected);
@@ -92,12 +165,23 @@ class BaseSunspecDevice {
92
165
  console.log(`${this.constructor.name} ${this.applianceId}: Reconnection successful after ${attempt} attempt(s) (opens=${postStats.opens}, closes=${postStats.closes}, current=${postStats.currentlyOpen})`);
93
166
  return true;
94
167
  }
168
+ this.consecutiveReconnectFailures += 1;
169
+ await this.onConnectionFailure(this.consecutiveReconnectFailures);
95
170
  }
96
171
  catch (error) {
97
172
  console.error(`${this.constructor.name} ${this.applianceId}: Reconnect attempt #${attempt} failed: ${error}`);
173
+ this.consecutiveReconnectFailures += 1;
174
+ await this.onConnectionFailure(this.consecutiveReconnectFailures);
98
175
  }
99
176
  return false;
100
177
  }
178
+ /**
179
+ * Hook for subclasses to react to a failed reconnect attempt. Called once
180
+ * per failed attempt with the running consecutive-failure count.
181
+ */
182
+ async onConnectionFailure(_consecutiveFailures) {
183
+ // Default: no-op. SunspecInverter overrides to publish a faulted status.
184
+ }
101
185
  /**
102
186
  * Mark the device as offline: close the underlying socket so the next readData()
103
187
  * cycle sees isConnected() === false and tryReconnect() can establish a fresh
@@ -152,6 +236,10 @@ exports.BaseSunspecDevice = BaseSunspecDevice;
152
236
  */
153
237
  class SunspecInverter extends BaseSunspecDevice {
154
238
  capabilities;
239
+ /** Emit a connection-lost faulted status after this many consecutive failed reconnect attempts. */
240
+ static CONNECTION_FAULT_THRESHOLD = 3;
241
+ storage;
242
+ errorState = { activeCodes: [], lastStatus: 'healthy' };
155
243
  constructor(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId = 1, port = 502, baseAddress = 40000, capabilities = [], retryConfig, appliance) {
156
244
  super(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId, port, baseAddress, retryConfig, appliance);
157
245
  this.capabilities = capabilities;
@@ -221,6 +309,7 @@ class SunspecInverter extends BaseSunspecDevice {
221
309
  if (mpptModel) {
222
310
  console.log(`MPPT model found for inverter ${this.networkDevice.hostname}`);
223
311
  }
312
+ await this.loadErrorState();
224
313
  this.startDataBusListening();
225
314
  }
226
315
  async disconnect() {
@@ -268,7 +357,12 @@ class SunspecInverter extends BaseSunspecDevice {
268
357
  }
269
358
  };
270
359
  messages.push(inverterMessage);
360
+ const statusMessage = await this.detectAndEmitStatusTransition(inverterData, timestamp);
361
+ if (statusMessage) {
362
+ messages.push(statusMessage);
363
+ }
271
364
  }
365
+ this.consecutiveReconnectFailures = 0;
272
366
  if (this.applianceId) {
273
367
  const appliance = await this.applianceManager.findApplianceById(this.applianceId);
274
368
  await this.applianceManager.updateAppliance(this.applianceId, {
@@ -286,6 +380,163 @@ class SunspecInverter extends BaseSunspecDevice {
286
380
  }
287
381
  return messages;
288
382
  }
383
+ /**
384
+ * Decode all active error bits from the inverter event registers into
385
+ * `EnyoApplianceErrorCode`s. Override in a subclass to customize the
386
+ * mapping wholesale, or override the per-register helpers
387
+ * (`mapEvt1Bit`, `mapEvt2Bit`, `mapVendorEventBit`) to customize a
388
+ * specific register — typical use-case is vendor-specific bit names.
389
+ */
390
+ decodeActiveErrors(data) {
391
+ const codes = [];
392
+ const codeIds = [];
393
+ const collect = (raw, mapper) => {
394
+ const value = raw ?? 0;
395
+ for (let bit = 0; bit < 32; bit++) {
396
+ if ((value & (1 << bit)) === 0)
397
+ continue;
398
+ const code = mapper(bit);
399
+ if (!code)
400
+ continue;
401
+ codes.push(code);
402
+ codeIds.push(code.code);
403
+ }
404
+ };
405
+ collect(data.events, (bit) => this.mapEvt1Bit(bit));
406
+ collect(data.events2, (bit) => this.mapEvt2Bit(bit));
407
+ collect(data.vendorEvents1, (bit) => this.mapVendorEventBit(1, bit));
408
+ collect(data.vendorEvents2, (bit) => this.mapVendorEventBit(2, bit));
409
+ collect(data.vendorEvents3, (bit) => this.mapVendorEventBit(3, bit));
410
+ collect(data.vendorEvents4, (bit) => this.mapVendorEventBit(4, bit));
411
+ codeIds.sort();
412
+ return { codes, codeIds };
413
+ }
414
+ /**
415
+ * Map a set bit in the standard Evt1 register to an error code with
416
+ * de/en translations. Override to customize standard-bit translations.
417
+ */
418
+ mapEvt1Bit(bit) {
419
+ const named = sunspec_interfaces_js_1.SunspecInverterEvent1[bit];
420
+ if (!named) {
421
+ return { code: `inverter_event1_bit_${bit}` };
422
+ }
423
+ const messages = SUNSPEC_INVERTER_EVENT1_MESSAGES[bit];
424
+ return messages ? { code: named, messages } : { code: named };
425
+ }
426
+ /**
427
+ * Map a set bit in the Evt2 register. Reserved in the SunSpec spec, so
428
+ * the default emits a generic code with no translation. Override for
429
+ * vendor-specific Evt2 semantics.
430
+ */
431
+ mapEvt2Bit(bit) {
432
+ return { code: `inverter_event2_bit_${bit}` };
433
+ }
434
+ /**
435
+ * Map a set bit in one of the four vendor-specific event registers.
436
+ * Override in a vendor-specific subclass to translate vendor bits.
437
+ * Return `undefined` to drop a bit entirely.
438
+ */
439
+ mapVendorEventBit(registerIndex, bit) {
440
+ return { code: `inverter_vendor_event${registerIndex}_bit_${bit}` };
441
+ }
442
+ hasErrorSetChanged(newCodeIds) {
443
+ const prev = this.errorState.activeCodes;
444
+ if (prev.length !== newCodeIds.length)
445
+ return true;
446
+ for (let i = 0; i < prev.length; i++) {
447
+ if (prev[i] !== newCodeIds[i])
448
+ return true;
449
+ }
450
+ return false;
451
+ }
452
+ buildStatusMessage(status, errorCodes, timestamp) {
453
+ return {
454
+ id: (0, node_crypto_1.randomUUID)(),
455
+ message: enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.ApplianceStateUpdateV1,
456
+ type: 'message',
457
+ source: enyo_source_enum_js_1.EnyoSourceEnum.Device,
458
+ applianceId: this.applianceId,
459
+ timestampIso: timestamp.toISOString(),
460
+ data: {
461
+ status,
462
+ errorCodes,
463
+ }
464
+ };
465
+ }
466
+ storageKey() {
467
+ return `sunspec-inverter-error-state-${this.applianceId}`;
468
+ }
469
+ async loadErrorState() {
470
+ try {
471
+ this.storage = this.energyApp.useStorage();
472
+ const loaded = await this.storage.load(this.storageKey());
473
+ if (loaded) {
474
+ this.errorState = loaded;
475
+ console.log(`Inverter ${this.applianceId}: loaded persisted error state (lastStatus=${loaded.lastStatus}, codes=[${loaded.activeCodes.join(', ')}])`);
476
+ }
477
+ }
478
+ catch (error) {
479
+ console.error(`Inverter ${this.applianceId}: failed to load persisted error state: ${error}`);
480
+ }
481
+ }
482
+ async persistErrorState() {
483
+ if (!this.storage)
484
+ return;
485
+ try {
486
+ await this.storage.save(this.storageKey(), this.errorState);
487
+ }
488
+ catch (error) {
489
+ console.error(`Inverter ${this.applianceId}: failed to persist error state: ${error}`);
490
+ }
491
+ }
492
+ async detectAndEmitStatusTransition(data, timestamp) {
493
+ if (!this.applianceId)
494
+ return undefined;
495
+ const { codes, codeIds } = this.decodeActiveErrors(data);
496
+ const recoveringFromConnectionLoss = this.errorState.lastStatus === 'connection_lost';
497
+ if (!recoveringFromConnectionLoss && !this.hasErrorSetChanged(codeIds)) {
498
+ return undefined;
499
+ }
500
+ const newStatus = codeIds.length === 0
501
+ ? enyo_appliance_js_1.EnyoApplianceStatusEnum.Healthy
502
+ : enyo_appliance_js_1.EnyoApplianceStatusEnum.Faulted;
503
+ this.errorState = {
504
+ evt1: data.events,
505
+ evt2: data.events2,
506
+ evtVnd1: data.vendorEvents1,
507
+ evtVnd2: data.vendorEvents2,
508
+ evtVnd3: data.vendorEvents3,
509
+ evtVnd4: data.vendorEvents4,
510
+ activeCodes: codeIds,
511
+ lastStatus: newStatus === enyo_appliance_js_1.EnyoApplianceStatusEnum.Healthy ? 'healthy' : 'faulted',
512
+ };
513
+ await this.persistErrorState();
514
+ console.log(`Inverter ${this.applianceId}: status transition -> ${newStatus} (codes=[${codeIds.join(', ')}])`);
515
+ return this.buildStatusMessage(newStatus, codes, timestamp);
516
+ }
517
+ async onConnectionFailure(consecutiveFailures) {
518
+ if (!this.applianceId || !this.dataBus)
519
+ return;
520
+ if (consecutiveFailures < SunspecInverter.CONNECTION_FAULT_THRESHOLD)
521
+ return;
522
+ if (this.errorState.lastStatus === 'connection_lost')
523
+ return;
524
+ const errorCodes = [{
525
+ code: sunspec_interfaces_js_1.SUNSPEC_CONNECTION_LOST_CODE,
526
+ messages: [
527
+ { language: 'en', message: 'Modbus connection to inverter lost' },
528
+ { language: 'de', message: 'Modbus-Verbindung zum Wechselrichter verloren' },
529
+ ]
530
+ }];
531
+ const message = this.buildStatusMessage(enyo_appliance_js_1.EnyoApplianceStatusEnum.Faulted, errorCodes, new Date());
532
+ this.errorState = {
533
+ activeCodes: [sunspec_interfaces_js_1.SUNSPEC_CONNECTION_LOST_CODE],
534
+ lastStatus: 'connection_lost',
535
+ };
536
+ await this.persistErrorState();
537
+ console.log(`Inverter ${this.applianceId}: emitting faulted (${sunspec_interfaces_js_1.SUNSPEC_CONNECTION_LOST_CODE}) after ${consecutiveFailures} consecutive reconnect failures`);
538
+ this.dataBus.sendMessage([message]);
539
+ }
289
540
  mapOperatingState(state) {
290
541
  if (!state)
291
542
  return enyo_data_bus_value_js_1.EnyoInverterStateEnum.Off;
@@ -1,6 +1,6 @@
1
- import { type IRetryConfig, type SunspecBatteryBaseData, SunspecBatteryCapability, type SunspecBatteryControls, SunspecInverterCapability, SunspecMeterCapability, SunspecStorageMode } from "./sunspec-interfaces.cjs";
1
+ import { type IRetryConfig, type SunspecBatteryBaseData, SunspecBatteryCapability, type SunspecBatteryControls, type SunspecInverterData, SunspecInverterCapability, SunspecMeterCapability, SunspecStorageMode } from "./sunspec-interfaces.cjs";
2
2
  import { ApplianceManager, EnergyApp } from "@enyo-energy/energy-app-sdk";
3
- import { type EnyoAppliance, EnyoApplianceName } from "@enyo-energy/energy-app-sdk/dist/types/enyo-appliance.js";
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
6
  import { ConnectionRetryManager } from "./connection-retry-manager.cjs";
@@ -23,6 +23,7 @@ export declare abstract class BaseSunspecDevice {
23
23
  protected dataBusListenerId?: string;
24
24
  protected dataBus?: EnergyAppDataBus;
25
25
  protected retryManager: ConnectionRetryManager;
26
+ protected consecutiveReconnectFailures: number;
26
27
  constructor(energyApp: EnergyApp, name: EnyoApplianceName[], networkDevice: EnyoNetworkDevice, sunspecClient: SunspecModbusClient, applianceManager: ApplianceManager, unitId?: number, port?: number, baseAddress?: number, retryConfig?: IRetryConfig, appliance?: EnyoAppliance);
27
28
  /**
28
29
  * Connect to the device and create/update the appliance
@@ -53,6 +54,11 @@ export declare abstract class BaseSunspecDevice {
53
54
  * Returns true if reconnection succeeded.
54
55
  */
55
56
  protected tryReconnect(): Promise<boolean>;
57
+ /**
58
+ * Hook for subclasses to react to a failed reconnect attempt. Called once
59
+ * per failed attempt with the running consecutive-failure count.
60
+ */
61
+ protected onConnectionFailure(_consecutiveFailures: number): Promise<void>;
56
62
  /**
57
63
  * Mark the device as offline: close the underlying socket so the next readData()
58
64
  * cycle sees isConnected() === false and tryReconnect() can establish a fresh
@@ -66,10 +72,49 @@ export declare abstract class BaseSunspecDevice {
66
72
  */
67
73
  export declare class SunspecInverter extends BaseSunspecDevice {
68
74
  private readonly capabilities;
75
+ /** Emit a connection-lost faulted status after this many consecutive failed reconnect attempts. */
76
+ private static readonly CONNECTION_FAULT_THRESHOLD;
77
+ private storage?;
78
+ private errorState;
69
79
  constructor(energyApp: EnergyApp, name: EnyoApplianceName[], networkDevice: EnyoNetworkDevice, sunspecClient: SunspecModbusClient, applianceManager: ApplianceManager, unitId?: number, port?: number, baseAddress?: number, capabilities?: SunspecInverterCapability[], retryConfig?: IRetryConfig, appliance?: EnyoAppliance);
70
80
  connect(): Promise<void>;
71
81
  disconnect(): Promise<void>;
72
82
  readData(clockId: string, resolution: '10s' | '30s' | '1m' | '15m'): Promise<EnyoDataBusMessage[]>;
83
+ /**
84
+ * Decode all active error bits from the inverter event registers into
85
+ * `EnyoApplianceErrorCode`s. Override in a subclass to customize the
86
+ * mapping wholesale, or override the per-register helpers
87
+ * (`mapEvt1Bit`, `mapEvt2Bit`, `mapVendorEventBit`) to customize a
88
+ * specific register — typical use-case is vendor-specific bit names.
89
+ */
90
+ protected decodeActiveErrors(data: SunspecInverterData): {
91
+ codes: EnyoApplianceErrorCode[];
92
+ codeIds: string[];
93
+ };
94
+ /**
95
+ * Map a set bit in the standard Evt1 register to an error code with
96
+ * de/en translations. Override to customize standard-bit translations.
97
+ */
98
+ protected mapEvt1Bit(bit: number): EnyoApplianceErrorCode | undefined;
99
+ /**
100
+ * Map a set bit in the Evt2 register. Reserved in the SunSpec spec, so
101
+ * the default emits a generic code with no translation. Override for
102
+ * vendor-specific Evt2 semantics.
103
+ */
104
+ protected mapEvt2Bit(bit: number): EnyoApplianceErrorCode | undefined;
105
+ /**
106
+ * Map a set bit in one of the four vendor-specific event registers.
107
+ * Override in a vendor-specific subclass to translate vendor bits.
108
+ * Return `undefined` to drop a bit entirely.
109
+ */
110
+ protected mapVendorEventBit(registerIndex: 1 | 2 | 3 | 4, bit: number): EnyoApplianceErrorCode | undefined;
111
+ private hasErrorSetChanged;
112
+ private buildStatusMessage;
113
+ private storageKey;
114
+ private loadErrorState;
115
+ private persistErrorState;
116
+ private detectAndEmitStatusTransition;
117
+ protected onConnectionFailure(consecutiveFailures: number): Promise<void>;
73
118
  private mapOperatingState;
74
119
  /**
75
120
  * Map MPPT data to DC string structure for data bus
@@ -3,7 +3,7 @@
3
3
  * SunSpec block interfaces with block numbers
4
4
  */
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.SunspecMeterCapability = 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.SunspecModelId = exports.DEFAULT_RETRY_CONFIG = void 0;
6
+ exports.SunspecMeterCapability = 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;
7
7
  exports.DEFAULT_RETRY_CONFIG = {
8
8
  phases: [
9
9
  { intervalMs: 10_000, durationMs: 60_000 }, // Phase 1: every 10s for 1 minute
@@ -33,6 +33,34 @@ var SunspecModelId;
33
33
  SunspecModelId[SunspecModelId["Controls"] = 123] = "Controls";
34
34
  SunspecModelId[SunspecModelId["EndMarker"] = 65535] = "EndMarker";
35
35
  })(SunspecModelId || (exports.SunspecModelId = SunspecModelId = {}));
36
+ /**
37
+ * Inverter Event 1 bit positions for Model 101/103
38
+ * Offset 40-41: Evt1 - Inverter event bitfield
39
+ */
40
+ var SunspecInverterEvent1;
41
+ (function (SunspecInverterEvent1) {
42
+ SunspecInverterEvent1[SunspecInverterEvent1["GROUND_FAULT"] = 0] = "GROUND_FAULT";
43
+ SunspecInverterEvent1[SunspecInverterEvent1["DC_OVER_VOLT"] = 1] = "DC_OVER_VOLT";
44
+ SunspecInverterEvent1[SunspecInverterEvent1["AC_DISCONNECT"] = 2] = "AC_DISCONNECT";
45
+ SunspecInverterEvent1[SunspecInverterEvent1["DC_DISCONNECT"] = 3] = "DC_DISCONNECT";
46
+ SunspecInverterEvent1[SunspecInverterEvent1["GRID_DISCONNECT"] = 4] = "GRID_DISCONNECT";
47
+ SunspecInverterEvent1[SunspecInverterEvent1["CABINET_OPEN"] = 5] = "CABINET_OPEN";
48
+ SunspecInverterEvent1[SunspecInverterEvent1["MANUAL_SHUTDOWN"] = 6] = "MANUAL_SHUTDOWN";
49
+ SunspecInverterEvent1[SunspecInverterEvent1["OVER_TEMP"] = 7] = "OVER_TEMP";
50
+ SunspecInverterEvent1[SunspecInverterEvent1["OVER_FREQUENCY"] = 8] = "OVER_FREQUENCY";
51
+ SunspecInverterEvent1[SunspecInverterEvent1["UNDER_FREQUENCY"] = 9] = "UNDER_FREQUENCY";
52
+ SunspecInverterEvent1[SunspecInverterEvent1["AC_OVER_VOLT"] = 10] = "AC_OVER_VOLT";
53
+ SunspecInverterEvent1[SunspecInverterEvent1["AC_UNDER_VOLT"] = 11] = "AC_UNDER_VOLT";
54
+ SunspecInverterEvent1[SunspecInverterEvent1["BLOWN_STRING_FUSE"] = 12] = "BLOWN_STRING_FUSE";
55
+ SunspecInverterEvent1[SunspecInverterEvent1["UNDER_TEMP"] = 13] = "UNDER_TEMP";
56
+ SunspecInverterEvent1[SunspecInverterEvent1["MEMORY_LOSS"] = 14] = "MEMORY_LOSS";
57
+ SunspecInverterEvent1[SunspecInverterEvent1["HW_TEST_FAILURE"] = 15] = "HW_TEST_FAILURE";
58
+ })(SunspecInverterEvent1 || (exports.SunspecInverterEvent1 = SunspecInverterEvent1 = {}));
59
+ /**
60
+ * Error code emitted when the modbus connection has been lost for several
61
+ * consecutive reconnect attempts.
62
+ */
63
+ exports.SUNSPEC_CONNECTION_LOST_CODE = 'modbus_connection_lost';
36
64
  /**
37
65
  * MPPT Operating State values for Model 160
38
66
  * These represent the DC module/string operating states
@@ -110,6 +110,48 @@ export interface SunspecInverterData extends SunspecBlock {
110
110
  vendorEvents3?: number;
111
111
  vendorEvents4?: number;
112
112
  }
113
+ /**
114
+ * Inverter Event 1 bit positions for Model 101/103
115
+ * Offset 40-41: Evt1 - Inverter event bitfield
116
+ */
117
+ export declare enum SunspecInverterEvent1 {
118
+ GROUND_FAULT = 0,
119
+ DC_OVER_VOLT = 1,
120
+ AC_DISCONNECT = 2,
121
+ DC_DISCONNECT = 3,
122
+ GRID_DISCONNECT = 4,
123
+ CABINET_OPEN = 5,
124
+ MANUAL_SHUTDOWN = 6,
125
+ OVER_TEMP = 7,
126
+ OVER_FREQUENCY = 8,
127
+ UNDER_FREQUENCY = 9,
128
+ AC_OVER_VOLT = 10,
129
+ AC_UNDER_VOLT = 11,
130
+ BLOWN_STRING_FUSE = 12,
131
+ UNDER_TEMP = 13,
132
+ MEMORY_LOSS = 14,
133
+ HW_TEST_FAILURE = 15
134
+ }
135
+ /**
136
+ * Error code emitted when the modbus connection has been lost for several
137
+ * consecutive reconnect attempts.
138
+ */
139
+ export declare const SUNSPEC_CONNECTION_LOST_CODE = "modbus_connection_lost";
140
+ /**
141
+ * Persisted error state for a SunSpec inverter, written to storage so the
142
+ * status reporter can detect transitions across process restarts and avoid
143
+ * re-emitting `faulted` for the same already-known errors.
144
+ */
145
+ export interface SunspecInverterPersistedErrorState {
146
+ evt1?: number;
147
+ evt2?: number;
148
+ evtVnd1?: number;
149
+ evtVnd2?: number;
150
+ evtVnd3?: number;
151
+ evtVnd4?: number;
152
+ activeCodes: string[];
153
+ lastStatus: 'healthy' | 'faulted' | 'connection_lost';
154
+ }
113
155
  /**
114
156
  * MPPT data structure based on Model 160
115
157
  *
@@ -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.48';
12
+ exports.SDK_VERSION = '0.0.49';
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.48";
8
+ export declare const SDK_VERSION = "0.0.49";
9
9
  /**
10
10
  * Gets the current SDK version.
11
11
  * @returns The semantic version string of the SDK
@@ -1,6 +1,6 @@
1
- import { type IRetryConfig, type SunspecBatteryBaseData, SunspecBatteryCapability, type SunspecBatteryControls, SunspecInverterCapability, SunspecMeterCapability, SunspecStorageMode } from "./sunspec-interfaces.js";
1
+ import { type IRetryConfig, type SunspecBatteryBaseData, SunspecBatteryCapability, type SunspecBatteryControls, type SunspecInverterData, SunspecInverterCapability, SunspecMeterCapability, SunspecStorageMode } from "./sunspec-interfaces.js";
2
2
  import { ApplianceManager, EnergyApp } from "@enyo-energy/energy-app-sdk";
3
- import { type EnyoAppliance, EnyoApplianceName } from "@enyo-energy/energy-app-sdk/dist/types/enyo-appliance.js";
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
6
  import { ConnectionRetryManager } from "./connection-retry-manager.js";
@@ -23,6 +23,7 @@ export declare abstract class BaseSunspecDevice {
23
23
  protected dataBusListenerId?: string;
24
24
  protected dataBus?: EnergyAppDataBus;
25
25
  protected retryManager: ConnectionRetryManager;
26
+ protected consecutiveReconnectFailures: number;
26
27
  constructor(energyApp: EnergyApp, name: EnyoApplianceName[], networkDevice: EnyoNetworkDevice, sunspecClient: SunspecModbusClient, applianceManager: ApplianceManager, unitId?: number, port?: number, baseAddress?: number, retryConfig?: IRetryConfig, appliance?: EnyoAppliance);
27
28
  /**
28
29
  * Connect to the device and create/update the appliance
@@ -53,6 +54,11 @@ export declare abstract class BaseSunspecDevice {
53
54
  * Returns true if reconnection succeeded.
54
55
  */
55
56
  protected tryReconnect(): Promise<boolean>;
57
+ /**
58
+ * Hook for subclasses to react to a failed reconnect attempt. Called once
59
+ * per failed attempt with the running consecutive-failure count.
60
+ */
61
+ protected onConnectionFailure(_consecutiveFailures: number): Promise<void>;
56
62
  /**
57
63
  * Mark the device as offline: close the underlying socket so the next readData()
58
64
  * cycle sees isConnected() === false and tryReconnect() can establish a fresh
@@ -66,10 +72,49 @@ export declare abstract class BaseSunspecDevice {
66
72
  */
67
73
  export declare class SunspecInverter extends BaseSunspecDevice {
68
74
  private readonly capabilities;
75
+ /** Emit a connection-lost faulted status after this many consecutive failed reconnect attempts. */
76
+ private static readonly CONNECTION_FAULT_THRESHOLD;
77
+ private storage?;
78
+ private errorState;
69
79
  constructor(energyApp: EnergyApp, name: EnyoApplianceName[], networkDevice: EnyoNetworkDevice, sunspecClient: SunspecModbusClient, applianceManager: ApplianceManager, unitId?: number, port?: number, baseAddress?: number, capabilities?: SunspecInverterCapability[], retryConfig?: IRetryConfig, appliance?: EnyoAppliance);
70
80
  connect(): Promise<void>;
71
81
  disconnect(): Promise<void>;
72
82
  readData(clockId: string, resolution: '10s' | '30s' | '1m' | '15m'): Promise<EnyoDataBusMessage[]>;
83
+ /**
84
+ * Decode all active error bits from the inverter event registers into
85
+ * `EnyoApplianceErrorCode`s. Override in a subclass to customize the
86
+ * mapping wholesale, or override the per-register helpers
87
+ * (`mapEvt1Bit`, `mapEvt2Bit`, `mapVendorEventBit`) to customize a
88
+ * specific register — typical use-case is vendor-specific bit names.
89
+ */
90
+ protected decodeActiveErrors(data: SunspecInverterData): {
91
+ codes: EnyoApplianceErrorCode[];
92
+ codeIds: string[];
93
+ };
94
+ /**
95
+ * Map a set bit in the standard Evt1 register to an error code with
96
+ * de/en translations. Override to customize standard-bit translations.
97
+ */
98
+ protected mapEvt1Bit(bit: number): EnyoApplianceErrorCode | undefined;
99
+ /**
100
+ * Map a set bit in the Evt2 register. Reserved in the SunSpec spec, so
101
+ * the default emits a generic code with no translation. Override for
102
+ * vendor-specific Evt2 semantics.
103
+ */
104
+ protected mapEvt2Bit(bit: number): EnyoApplianceErrorCode | undefined;
105
+ /**
106
+ * Map a set bit in one of the four vendor-specific event registers.
107
+ * Override in a vendor-specific subclass to translate vendor bits.
108
+ * Return `undefined` to drop a bit entirely.
109
+ */
110
+ protected mapVendorEventBit(registerIndex: 1 | 2 | 3 | 4, bit: number): EnyoApplianceErrorCode | undefined;
111
+ private hasErrorSetChanged;
112
+ private buildStatusMessage;
113
+ private storageKey;
114
+ private loadErrorState;
115
+ private persistErrorState;
116
+ private detectAndEmitStatusTransition;
117
+ protected onConnectionFailure(consecutiveFailures: number): Promise<void>;
73
118
  private mapOperatingState;
74
119
  /**
75
120
  * Map MPPT data to DC string structure for data bus
@@ -1,11 +1,82 @@
1
- import { SunspecBatteryChargeState, SunspecInverterCapability, SunspecModelId, SunspecMPPTOperatingState, SunspecStorageMode } from "./sunspec-interfaces.js";
1
+ import { SunspecBatteryChargeState, SunspecInverterCapability, SunspecInverterEvent1, SunspecModelId, SunspecMPPTOperatingState, SunspecStorageMode, SUNSPEC_CONNECTION_LOST_CODE } from "./sunspec-interfaces.js";
2
2
  import { randomUUID } from "node:crypto";
3
- import { EnyoApplianceConnectionType, EnyoApplianceStateEnum, EnyoApplianceTopologyFeatureEnum, EnyoApplianceTypeEnum } from "@enyo-energy/energy-app-sdk/dist/types/enyo-appliance.js";
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";
5
5
  import { EnyoBatteryStateEnum, EnyoCommandAcknowledgeAnswerEnum, EnyoDataBusMessageEnum, EnyoInverterStateEnum, EnyoStringStateEnum } from "@enyo-energy/energy-app-sdk/dist/types/enyo-data-bus-value.js";
6
6
  import { EnyoSourceEnum } from "@enyo-energy/energy-app-sdk/dist/types/enyo-source.enum.js";
7
7
  import { EnyoMeterApplianceAvailableFeaturesEnum } from "@enyo-energy/energy-app-sdk/dist/types/enyo-meter-appliance.js";
8
8
  import { EnyoBatteryFeature, EnyoBatteryStorageMode } from "@enyo-energy/energy-app-sdk/dist/types/enyo-battery-appliance.js";
9
+ /**
10
+ * Translated messages (en, de) for the standard SunSpec inverter Evt1 bits.
11
+ * Consumers can render these directly to end users; if no entry matches a
12
+ * locale, the SDK contract is to fall back to the machine-readable `code`.
13
+ */
14
+ const SUNSPEC_INVERTER_EVENT1_MESSAGES = {
15
+ [SunspecInverterEvent1.GROUND_FAULT]: [
16
+ { language: 'en', message: 'Ground fault detected' },
17
+ { language: 'de', message: 'Erdschluss erkannt' },
18
+ ],
19
+ [SunspecInverterEvent1.DC_OVER_VOLT]: [
20
+ { language: 'en', message: 'DC over-voltage' },
21
+ { language: 'de', message: 'DC-Überspannung' },
22
+ ],
23
+ [SunspecInverterEvent1.AC_DISCONNECT]: [
24
+ { language: 'en', message: 'AC disconnect open' },
25
+ { language: 'de', message: 'AC-Trennschalter offen' },
26
+ ],
27
+ [SunspecInverterEvent1.DC_DISCONNECT]: [
28
+ { language: 'en', message: 'DC disconnect open' },
29
+ { language: 'de', message: 'DC-Trennschalter offen' },
30
+ ],
31
+ [SunspecInverterEvent1.GRID_DISCONNECT]: [
32
+ { language: 'en', message: 'Grid disconnected' },
33
+ { language: 'de', message: 'Netz getrennt' },
34
+ ],
35
+ [SunspecInverterEvent1.CABINET_OPEN]: [
36
+ { language: 'en', message: 'Cabinet open' },
37
+ { language: 'de', message: 'Gehäuse offen' },
38
+ ],
39
+ [SunspecInverterEvent1.MANUAL_SHUTDOWN]: [
40
+ { language: 'en', message: 'Manual shutdown' },
41
+ { language: 'de', message: 'Manuelle Abschaltung' },
42
+ ],
43
+ [SunspecInverterEvent1.OVER_TEMP]: [
44
+ { language: 'en', message: 'Over temperature' },
45
+ { language: 'de', message: 'Übertemperatur' },
46
+ ],
47
+ [SunspecInverterEvent1.OVER_FREQUENCY]: [
48
+ { language: 'en', message: 'AC frequency above limit' },
49
+ { language: 'de', message: 'AC-Frequenz zu hoch' },
50
+ ],
51
+ [SunspecInverterEvent1.UNDER_FREQUENCY]: [
52
+ { language: 'en', message: 'AC frequency below limit' },
53
+ { language: 'de', message: 'AC-Frequenz zu niedrig' },
54
+ ],
55
+ [SunspecInverterEvent1.AC_OVER_VOLT]: [
56
+ { language: 'en', message: 'AC over-voltage' },
57
+ { language: 'de', message: 'AC-Überspannung' },
58
+ ],
59
+ [SunspecInverterEvent1.AC_UNDER_VOLT]: [
60
+ { language: 'en', message: 'AC under-voltage' },
61
+ { language: 'de', message: 'AC-Unterspannung' },
62
+ ],
63
+ [SunspecInverterEvent1.BLOWN_STRING_FUSE]: [
64
+ { language: 'en', message: 'Blown string fuse' },
65
+ { language: 'de', message: 'Strangsicherung defekt' },
66
+ ],
67
+ [SunspecInverterEvent1.UNDER_TEMP]: [
68
+ { language: 'en', message: 'Under temperature' },
69
+ { language: 'de', message: 'Untertemperatur' },
70
+ ],
71
+ [SunspecInverterEvent1.MEMORY_LOSS]: [
72
+ { language: 'en', message: 'Memory loss' },
73
+ { language: 'de', message: 'Speicherverlust' },
74
+ ],
75
+ [SunspecInverterEvent1.HW_TEST_FAILURE]: [
76
+ { language: 'en', message: 'Hardware self-test failed' },
77
+ { language: 'de', message: 'Hardware-Selbsttest fehlgeschlagen' },
78
+ ],
79
+ };
9
80
  /**
10
81
  * Base abstract class for all Sunspec devices
11
82
  */
@@ -23,6 +94,7 @@ export class BaseSunspecDevice {
23
94
  dataBusListenerId;
24
95
  dataBus;
25
96
  retryManager;
97
+ consecutiveReconnectFailures = 0;
26
98
  constructor(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId = 1, port = 502, baseAddress = 40000, retryConfig, appliance) {
27
99
  this.energyApp = energyApp;
28
100
  this.name = name;
@@ -81,6 +153,7 @@ export class BaseSunspecDevice {
81
153
  // Re-discover models after reconnect
82
154
  await this.sunspecClient.discoverModels(this.baseAddress);
83
155
  this.retryManager.reset();
156
+ this.consecutiveReconnectFailures = 0;
84
157
  // Update appliance state to Connected
85
158
  if (this.applianceId) {
86
159
  await this.applianceManager.updateApplianceState(this.applianceId, EnyoApplianceConnectionType.Connector, EnyoApplianceStateEnum.Connected);
@@ -89,12 +162,23 @@ export class BaseSunspecDevice {
89
162
  console.log(`${this.constructor.name} ${this.applianceId}: Reconnection successful after ${attempt} attempt(s) (opens=${postStats.opens}, closes=${postStats.closes}, current=${postStats.currentlyOpen})`);
90
163
  return true;
91
164
  }
165
+ this.consecutiveReconnectFailures += 1;
166
+ await this.onConnectionFailure(this.consecutiveReconnectFailures);
92
167
  }
93
168
  catch (error) {
94
169
  console.error(`${this.constructor.name} ${this.applianceId}: Reconnect attempt #${attempt} failed: ${error}`);
170
+ this.consecutiveReconnectFailures += 1;
171
+ await this.onConnectionFailure(this.consecutiveReconnectFailures);
95
172
  }
96
173
  return false;
97
174
  }
175
+ /**
176
+ * Hook for subclasses to react to a failed reconnect attempt. Called once
177
+ * per failed attempt with the running consecutive-failure count.
178
+ */
179
+ async onConnectionFailure(_consecutiveFailures) {
180
+ // Default: no-op. SunspecInverter overrides to publish a faulted status.
181
+ }
98
182
  /**
99
183
  * Mark the device as offline: close the underlying socket so the next readData()
100
184
  * cycle sees isConnected() === false and tryReconnect() can establish a fresh
@@ -148,6 +232,10 @@ export class BaseSunspecDevice {
148
232
  */
149
233
  export class SunspecInverter extends BaseSunspecDevice {
150
234
  capabilities;
235
+ /** Emit a connection-lost faulted status after this many consecutive failed reconnect attempts. */
236
+ static CONNECTION_FAULT_THRESHOLD = 3;
237
+ storage;
238
+ errorState = { activeCodes: [], lastStatus: 'healthy' };
151
239
  constructor(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId = 1, port = 502, baseAddress = 40000, capabilities = [], retryConfig, appliance) {
152
240
  super(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId, port, baseAddress, retryConfig, appliance);
153
241
  this.capabilities = capabilities;
@@ -217,6 +305,7 @@ export class SunspecInverter extends BaseSunspecDevice {
217
305
  if (mpptModel) {
218
306
  console.log(`MPPT model found for inverter ${this.networkDevice.hostname}`);
219
307
  }
308
+ await this.loadErrorState();
220
309
  this.startDataBusListening();
221
310
  }
222
311
  async disconnect() {
@@ -264,7 +353,12 @@ export class SunspecInverter extends BaseSunspecDevice {
264
353
  }
265
354
  };
266
355
  messages.push(inverterMessage);
356
+ const statusMessage = await this.detectAndEmitStatusTransition(inverterData, timestamp);
357
+ if (statusMessage) {
358
+ messages.push(statusMessage);
359
+ }
267
360
  }
361
+ this.consecutiveReconnectFailures = 0;
268
362
  if (this.applianceId) {
269
363
  const appliance = await this.applianceManager.findApplianceById(this.applianceId);
270
364
  await this.applianceManager.updateAppliance(this.applianceId, {
@@ -282,6 +376,163 @@ export class SunspecInverter extends BaseSunspecDevice {
282
376
  }
283
377
  return messages;
284
378
  }
379
+ /**
380
+ * Decode all active error bits from the inverter event registers into
381
+ * `EnyoApplianceErrorCode`s. Override in a subclass to customize the
382
+ * mapping wholesale, or override the per-register helpers
383
+ * (`mapEvt1Bit`, `mapEvt2Bit`, `mapVendorEventBit`) to customize a
384
+ * specific register — typical use-case is vendor-specific bit names.
385
+ */
386
+ decodeActiveErrors(data) {
387
+ const codes = [];
388
+ const codeIds = [];
389
+ const collect = (raw, mapper) => {
390
+ const value = raw ?? 0;
391
+ for (let bit = 0; bit < 32; bit++) {
392
+ if ((value & (1 << bit)) === 0)
393
+ continue;
394
+ const code = mapper(bit);
395
+ if (!code)
396
+ continue;
397
+ codes.push(code);
398
+ codeIds.push(code.code);
399
+ }
400
+ };
401
+ collect(data.events, (bit) => this.mapEvt1Bit(bit));
402
+ collect(data.events2, (bit) => this.mapEvt2Bit(bit));
403
+ collect(data.vendorEvents1, (bit) => this.mapVendorEventBit(1, bit));
404
+ collect(data.vendorEvents2, (bit) => this.mapVendorEventBit(2, bit));
405
+ collect(data.vendorEvents3, (bit) => this.mapVendorEventBit(3, bit));
406
+ collect(data.vendorEvents4, (bit) => this.mapVendorEventBit(4, bit));
407
+ codeIds.sort();
408
+ return { codes, codeIds };
409
+ }
410
+ /**
411
+ * Map a set bit in the standard Evt1 register to an error code with
412
+ * de/en translations. Override to customize standard-bit translations.
413
+ */
414
+ mapEvt1Bit(bit) {
415
+ const named = SunspecInverterEvent1[bit];
416
+ if (!named) {
417
+ return { code: `inverter_event1_bit_${bit}` };
418
+ }
419
+ const messages = SUNSPEC_INVERTER_EVENT1_MESSAGES[bit];
420
+ return messages ? { code: named, messages } : { code: named };
421
+ }
422
+ /**
423
+ * Map a set bit in the Evt2 register. Reserved in the SunSpec spec, so
424
+ * the default emits a generic code with no translation. Override for
425
+ * vendor-specific Evt2 semantics.
426
+ */
427
+ mapEvt2Bit(bit) {
428
+ return { code: `inverter_event2_bit_${bit}` };
429
+ }
430
+ /**
431
+ * Map a set bit in one of the four vendor-specific event registers.
432
+ * Override in a vendor-specific subclass to translate vendor bits.
433
+ * Return `undefined` to drop a bit entirely.
434
+ */
435
+ mapVendorEventBit(registerIndex, bit) {
436
+ return { code: `inverter_vendor_event${registerIndex}_bit_${bit}` };
437
+ }
438
+ hasErrorSetChanged(newCodeIds) {
439
+ const prev = this.errorState.activeCodes;
440
+ if (prev.length !== newCodeIds.length)
441
+ return true;
442
+ for (let i = 0; i < prev.length; i++) {
443
+ if (prev[i] !== newCodeIds[i])
444
+ return true;
445
+ }
446
+ return false;
447
+ }
448
+ buildStatusMessage(status, errorCodes, timestamp) {
449
+ return {
450
+ id: randomUUID(),
451
+ message: EnyoDataBusMessageEnum.ApplianceStateUpdateV1,
452
+ type: 'message',
453
+ source: EnyoSourceEnum.Device,
454
+ applianceId: this.applianceId,
455
+ timestampIso: timestamp.toISOString(),
456
+ data: {
457
+ status,
458
+ errorCodes,
459
+ }
460
+ };
461
+ }
462
+ storageKey() {
463
+ return `sunspec-inverter-error-state-${this.applianceId}`;
464
+ }
465
+ async loadErrorState() {
466
+ try {
467
+ this.storage = this.energyApp.useStorage();
468
+ const loaded = await this.storage.load(this.storageKey());
469
+ if (loaded) {
470
+ this.errorState = loaded;
471
+ console.log(`Inverter ${this.applianceId}: loaded persisted error state (lastStatus=${loaded.lastStatus}, codes=[${loaded.activeCodes.join(', ')}])`);
472
+ }
473
+ }
474
+ catch (error) {
475
+ console.error(`Inverter ${this.applianceId}: failed to load persisted error state: ${error}`);
476
+ }
477
+ }
478
+ async persistErrorState() {
479
+ if (!this.storage)
480
+ return;
481
+ try {
482
+ await this.storage.save(this.storageKey(), this.errorState);
483
+ }
484
+ catch (error) {
485
+ console.error(`Inverter ${this.applianceId}: failed to persist error state: ${error}`);
486
+ }
487
+ }
488
+ async detectAndEmitStatusTransition(data, timestamp) {
489
+ if (!this.applianceId)
490
+ return undefined;
491
+ const { codes, codeIds } = this.decodeActiveErrors(data);
492
+ const recoveringFromConnectionLoss = this.errorState.lastStatus === 'connection_lost';
493
+ if (!recoveringFromConnectionLoss && !this.hasErrorSetChanged(codeIds)) {
494
+ return undefined;
495
+ }
496
+ const newStatus = codeIds.length === 0
497
+ ? EnyoApplianceStatusEnum.Healthy
498
+ : EnyoApplianceStatusEnum.Faulted;
499
+ this.errorState = {
500
+ evt1: data.events,
501
+ evt2: data.events2,
502
+ evtVnd1: data.vendorEvents1,
503
+ evtVnd2: data.vendorEvents2,
504
+ evtVnd3: data.vendorEvents3,
505
+ evtVnd4: data.vendorEvents4,
506
+ activeCodes: codeIds,
507
+ lastStatus: newStatus === EnyoApplianceStatusEnum.Healthy ? 'healthy' : 'faulted',
508
+ };
509
+ await this.persistErrorState();
510
+ console.log(`Inverter ${this.applianceId}: status transition -> ${newStatus} (codes=[${codeIds.join(', ')}])`);
511
+ return this.buildStatusMessage(newStatus, codes, timestamp);
512
+ }
513
+ async onConnectionFailure(consecutiveFailures) {
514
+ if (!this.applianceId || !this.dataBus)
515
+ return;
516
+ if (consecutiveFailures < SunspecInverter.CONNECTION_FAULT_THRESHOLD)
517
+ return;
518
+ if (this.errorState.lastStatus === 'connection_lost')
519
+ return;
520
+ const errorCodes = [{
521
+ code: SUNSPEC_CONNECTION_LOST_CODE,
522
+ messages: [
523
+ { language: 'en', message: 'Modbus connection to inverter lost' },
524
+ { language: 'de', message: 'Modbus-Verbindung zum Wechselrichter verloren' },
525
+ ]
526
+ }];
527
+ const message = this.buildStatusMessage(EnyoApplianceStatusEnum.Faulted, errorCodes, new Date());
528
+ this.errorState = {
529
+ activeCodes: [SUNSPEC_CONNECTION_LOST_CODE],
530
+ lastStatus: 'connection_lost',
531
+ };
532
+ await this.persistErrorState();
533
+ console.log(`Inverter ${this.applianceId}: emitting faulted (${SUNSPEC_CONNECTION_LOST_CODE}) after ${consecutiveFailures} consecutive reconnect failures`);
534
+ this.dataBus.sendMessage([message]);
535
+ }
285
536
  mapOperatingState(state) {
286
537
  if (!state)
287
538
  return EnyoInverterStateEnum.Off;
@@ -110,6 +110,48 @@ export interface SunspecInverterData extends SunspecBlock {
110
110
  vendorEvents3?: number;
111
111
  vendorEvents4?: number;
112
112
  }
113
+ /**
114
+ * Inverter Event 1 bit positions for Model 101/103
115
+ * Offset 40-41: Evt1 - Inverter event bitfield
116
+ */
117
+ export declare enum SunspecInverterEvent1 {
118
+ GROUND_FAULT = 0,
119
+ DC_OVER_VOLT = 1,
120
+ AC_DISCONNECT = 2,
121
+ DC_DISCONNECT = 3,
122
+ GRID_DISCONNECT = 4,
123
+ CABINET_OPEN = 5,
124
+ MANUAL_SHUTDOWN = 6,
125
+ OVER_TEMP = 7,
126
+ OVER_FREQUENCY = 8,
127
+ UNDER_FREQUENCY = 9,
128
+ AC_OVER_VOLT = 10,
129
+ AC_UNDER_VOLT = 11,
130
+ BLOWN_STRING_FUSE = 12,
131
+ UNDER_TEMP = 13,
132
+ MEMORY_LOSS = 14,
133
+ HW_TEST_FAILURE = 15
134
+ }
135
+ /**
136
+ * Error code emitted when the modbus connection has been lost for several
137
+ * consecutive reconnect attempts.
138
+ */
139
+ export declare const SUNSPEC_CONNECTION_LOST_CODE = "modbus_connection_lost";
140
+ /**
141
+ * Persisted error state for a SunSpec inverter, written to storage so the
142
+ * status reporter can detect transitions across process restarts and avoid
143
+ * re-emitting `faulted` for the same already-known errors.
144
+ */
145
+ export interface SunspecInverterPersistedErrorState {
146
+ evt1?: number;
147
+ evt2?: number;
148
+ evtVnd1?: number;
149
+ evtVnd2?: number;
150
+ evtVnd3?: number;
151
+ evtVnd4?: number;
152
+ activeCodes: string[];
153
+ lastStatus: 'healthy' | 'faulted' | 'connection_lost';
154
+ }
113
155
  /**
114
156
  * MPPT data structure based on Model 160
115
157
  *
@@ -30,6 +30,34 @@ export var SunspecModelId;
30
30
  SunspecModelId[SunspecModelId["Controls"] = 123] = "Controls";
31
31
  SunspecModelId[SunspecModelId["EndMarker"] = 65535] = "EndMarker";
32
32
  })(SunspecModelId || (SunspecModelId = {}));
33
+ /**
34
+ * Inverter Event 1 bit positions for Model 101/103
35
+ * Offset 40-41: Evt1 - Inverter event bitfield
36
+ */
37
+ export var SunspecInverterEvent1;
38
+ (function (SunspecInverterEvent1) {
39
+ SunspecInverterEvent1[SunspecInverterEvent1["GROUND_FAULT"] = 0] = "GROUND_FAULT";
40
+ SunspecInverterEvent1[SunspecInverterEvent1["DC_OVER_VOLT"] = 1] = "DC_OVER_VOLT";
41
+ SunspecInverterEvent1[SunspecInverterEvent1["AC_DISCONNECT"] = 2] = "AC_DISCONNECT";
42
+ SunspecInverterEvent1[SunspecInverterEvent1["DC_DISCONNECT"] = 3] = "DC_DISCONNECT";
43
+ SunspecInverterEvent1[SunspecInverterEvent1["GRID_DISCONNECT"] = 4] = "GRID_DISCONNECT";
44
+ SunspecInverterEvent1[SunspecInverterEvent1["CABINET_OPEN"] = 5] = "CABINET_OPEN";
45
+ SunspecInverterEvent1[SunspecInverterEvent1["MANUAL_SHUTDOWN"] = 6] = "MANUAL_SHUTDOWN";
46
+ SunspecInverterEvent1[SunspecInverterEvent1["OVER_TEMP"] = 7] = "OVER_TEMP";
47
+ SunspecInverterEvent1[SunspecInverterEvent1["OVER_FREQUENCY"] = 8] = "OVER_FREQUENCY";
48
+ SunspecInverterEvent1[SunspecInverterEvent1["UNDER_FREQUENCY"] = 9] = "UNDER_FREQUENCY";
49
+ SunspecInverterEvent1[SunspecInverterEvent1["AC_OVER_VOLT"] = 10] = "AC_OVER_VOLT";
50
+ SunspecInverterEvent1[SunspecInverterEvent1["AC_UNDER_VOLT"] = 11] = "AC_UNDER_VOLT";
51
+ SunspecInverterEvent1[SunspecInverterEvent1["BLOWN_STRING_FUSE"] = 12] = "BLOWN_STRING_FUSE";
52
+ SunspecInverterEvent1[SunspecInverterEvent1["UNDER_TEMP"] = 13] = "UNDER_TEMP";
53
+ SunspecInverterEvent1[SunspecInverterEvent1["MEMORY_LOSS"] = 14] = "MEMORY_LOSS";
54
+ SunspecInverterEvent1[SunspecInverterEvent1["HW_TEST_FAILURE"] = 15] = "HW_TEST_FAILURE";
55
+ })(SunspecInverterEvent1 || (SunspecInverterEvent1 = {}));
56
+ /**
57
+ * Error code emitted when the modbus connection has been lost for several
58
+ * consecutive reconnect attempts.
59
+ */
60
+ export const SUNSPEC_CONNECTION_LOST_CODE = 'modbus_connection_lost';
33
61
  /**
34
62
  * MPPT Operating State values for Model 160
35
63
  * These represent the DC module/string operating states
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.48";
8
+ export declare const SDK_VERSION = "0.0.49";
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.48';
8
+ export const SDK_VERSION = '0.0.49';
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.48",
3
+ "version": "0.0.49",
4
4
  "description": "enyo Energy Sunspec SDK",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",