@enyo-energy/sunspec-sdk 0.0.48 → 0.0.50

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() {
@@ -246,7 +335,7 @@ class SunspecInverter extends BaseSunspecDevice {
246
335
  const dcStrings = this.mapMPPTToStrings(mpptDataList);
247
336
  if (inverterData) {
248
337
  const pvPowerW = dcStrings.reduce((sum, s) => sum + (s.powerW || 0), 0);
249
- console.log(`Got PV Power from DC strings: ${pvPowerW}W`);
338
+ console.debug(`Got PV Power from DC strings: ${pvPowerW}W`);
250
339
  const inverterMessage = {
251
340
  id: (0, node_crypto_1.randomUUID)(),
252
341
  message: enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.InverterValuesUpdateV1,
@@ -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,166 @@ 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 returns `undefined` — Evt2 bits are not treated as errors.
429
+ * Override in a vendor-specific subclass to opt specific bits in as
430
+ * errors.
431
+ */
432
+ mapEvt2Bit(_bit) {
433
+ return undefined;
434
+ }
435
+ /**
436
+ * Map a set bit in one of the four vendor-specific event registers.
437
+ * Vendor bits are not standardized and many are informational rather
438
+ * than faults, so the default returns `undefined` and no error code is
439
+ * emitted. Override in a vendor-specific subclass to opt specific bits
440
+ * in as errors.
441
+ */
442
+ mapVendorEventBit(_registerIndex, _bit) {
443
+ return undefined;
444
+ }
445
+ hasErrorSetChanged(newCodeIds) {
446
+ const prev = this.errorState.activeCodes;
447
+ if (prev.length !== newCodeIds.length)
448
+ return true;
449
+ for (let i = 0; i < prev.length; i++) {
450
+ if (prev[i] !== newCodeIds[i])
451
+ return true;
452
+ }
453
+ return false;
454
+ }
455
+ buildStatusMessage(status, errorCodes, timestamp) {
456
+ return {
457
+ id: (0, node_crypto_1.randomUUID)(),
458
+ message: enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.ApplianceStateUpdateV1,
459
+ type: 'message',
460
+ source: enyo_source_enum_js_1.EnyoSourceEnum.Device,
461
+ applianceId: this.applianceId,
462
+ timestampIso: timestamp.toISOString(),
463
+ data: {
464
+ status,
465
+ errorCodes,
466
+ }
467
+ };
468
+ }
469
+ storageKey() {
470
+ return `sunspec-inverter-error-state-${this.applianceId}`;
471
+ }
472
+ async loadErrorState() {
473
+ try {
474
+ this.storage = this.energyApp.useStorage();
475
+ const loaded = await this.storage.load(this.storageKey());
476
+ if (loaded) {
477
+ this.errorState = loaded;
478
+ console.log(`Inverter ${this.applianceId}: loaded persisted error state (lastStatus=${loaded.lastStatus}, codes=[${loaded.activeCodes.join(', ')}])`);
479
+ }
480
+ }
481
+ catch (error) {
482
+ console.error(`Inverter ${this.applianceId}: failed to load persisted error state: ${error}`);
483
+ }
484
+ }
485
+ async persistErrorState() {
486
+ if (!this.storage)
487
+ return;
488
+ try {
489
+ await this.storage.save(this.storageKey(), this.errorState);
490
+ }
491
+ catch (error) {
492
+ console.error(`Inverter ${this.applianceId}: failed to persist error state: ${error}`);
493
+ }
494
+ }
495
+ async detectAndEmitStatusTransition(data, timestamp) {
496
+ if (!this.applianceId)
497
+ return undefined;
498
+ const { codes, codeIds } = this.decodeActiveErrors(data);
499
+ const recoveringFromConnectionLoss = this.errorState.lastStatus === 'connection_lost';
500
+ if (!recoveringFromConnectionLoss && !this.hasErrorSetChanged(codeIds)) {
501
+ return undefined;
502
+ }
503
+ const newStatus = codeIds.length === 0
504
+ ? enyo_appliance_js_1.EnyoApplianceStatusEnum.Healthy
505
+ : enyo_appliance_js_1.EnyoApplianceStatusEnum.Faulted;
506
+ this.errorState = {
507
+ evt1: data.events,
508
+ evt2: data.events2,
509
+ evtVnd1: data.vendorEvents1,
510
+ evtVnd2: data.vendorEvents2,
511
+ evtVnd3: data.vendorEvents3,
512
+ evtVnd4: data.vendorEvents4,
513
+ activeCodes: codeIds,
514
+ lastStatus: newStatus === enyo_appliance_js_1.EnyoApplianceStatusEnum.Healthy ? 'healthy' : 'faulted',
515
+ };
516
+ await this.persistErrorState();
517
+ console.log(`Inverter ${this.applianceId}: status transition -> ${newStatus} (codes=[${codeIds.join(', ')}])`);
518
+ return this.buildStatusMessage(newStatus, codes, timestamp);
519
+ }
520
+ async onConnectionFailure(consecutiveFailures) {
521
+ if (!this.applianceId || !this.dataBus)
522
+ return;
523
+ if (consecutiveFailures < SunspecInverter.CONNECTION_FAULT_THRESHOLD)
524
+ return;
525
+ if (this.errorState.lastStatus === 'connection_lost')
526
+ return;
527
+ const errorCodes = [{
528
+ code: sunspec_interfaces_js_1.SUNSPEC_CONNECTION_LOST_CODE,
529
+ messages: [
530
+ { language: 'en', message: 'Modbus connection to inverter lost' },
531
+ { language: 'de', message: 'Modbus-Verbindung zum Wechselrichter verloren' },
532
+ ]
533
+ }];
534
+ const message = this.buildStatusMessage(enyo_appliance_js_1.EnyoApplianceStatusEnum.Faulted, errorCodes, new Date());
535
+ this.errorState = {
536
+ activeCodes: [sunspec_interfaces_js_1.SUNSPEC_CONNECTION_LOST_CODE],
537
+ lastStatus: 'connection_lost',
538
+ };
539
+ await this.persistErrorState();
540
+ console.log(`Inverter ${this.applianceId}: emitting faulted (${sunspec_interfaces_js_1.SUNSPEC_CONNECTION_LOST_CODE}) after ${consecutiveFailures} consecutive reconnect failures`);
541
+ this.dataBus.sendMessage([message]);
542
+ }
289
543
  mapOperatingState(state) {
290
544
  if (!state)
291
545
  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,52 @@ 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 returns `undefined` — Evt2 bits are not treated as errors.
102
+ * Override in a vendor-specific subclass to opt specific bits in as
103
+ * errors.
104
+ */
105
+ protected mapEvt2Bit(_bit: number): EnyoApplianceErrorCode | undefined;
106
+ /**
107
+ * Map a set bit in one of the four vendor-specific event registers.
108
+ * Vendor bits are not standardized and many are informational rather
109
+ * than faults, so the default returns `undefined` and no error code is
110
+ * emitted. Override in a vendor-specific subclass to opt specific bits
111
+ * in as errors.
112
+ */
113
+ protected mapVendorEventBit(_registerIndex: 1 | 2 | 3 | 4, _bit: number): EnyoApplianceErrorCode | undefined;
114
+ private hasErrorSetChanged;
115
+ private buildStatusMessage;
116
+ private storageKey;
117
+ private loadErrorState;
118
+ private persistErrorState;
119
+ private detectAndEmitStatusTransition;
120
+ protected onConnectionFailure(consecutiveFailures: number): Promise<void>;
73
121
  private mapOperatingState;
74
122
  /**
75
123
  * 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.50';
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.50";
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,52 @@ 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 returns `undefined` — Evt2 bits are not treated as errors.
102
+ * Override in a vendor-specific subclass to opt specific bits in as
103
+ * errors.
104
+ */
105
+ protected mapEvt2Bit(_bit: number): EnyoApplianceErrorCode | undefined;
106
+ /**
107
+ * Map a set bit in one of the four vendor-specific event registers.
108
+ * Vendor bits are not standardized and many are informational rather
109
+ * than faults, so the default returns `undefined` and no error code is
110
+ * emitted. Override in a vendor-specific subclass to opt specific bits
111
+ * in as errors.
112
+ */
113
+ protected mapVendorEventBit(_registerIndex: 1 | 2 | 3 | 4, _bit: number): EnyoApplianceErrorCode | undefined;
114
+ private hasErrorSetChanged;
115
+ private buildStatusMessage;
116
+ private storageKey;
117
+ private loadErrorState;
118
+ private persistErrorState;
119
+ private detectAndEmitStatusTransition;
120
+ protected onConnectionFailure(consecutiveFailures: number): Promise<void>;
73
121
  private mapOperatingState;
74
122
  /**
75
123
  * 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() {
@@ -242,7 +331,7 @@ export class SunspecInverter extends BaseSunspecDevice {
242
331
  const dcStrings = this.mapMPPTToStrings(mpptDataList);
243
332
  if (inverterData) {
244
333
  const pvPowerW = dcStrings.reduce((sum, s) => sum + (s.powerW || 0), 0);
245
- console.log(`Got PV Power from DC strings: ${pvPowerW}W`);
334
+ console.debug(`Got PV Power from DC strings: ${pvPowerW}W`);
246
335
  const inverterMessage = {
247
336
  id: randomUUID(),
248
337
  message: EnyoDataBusMessageEnum.InverterValuesUpdateV1,
@@ -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,166 @@ 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 returns `undefined` — Evt2 bits are not treated as errors.
425
+ * Override in a vendor-specific subclass to opt specific bits in as
426
+ * errors.
427
+ */
428
+ mapEvt2Bit(_bit) {
429
+ return undefined;
430
+ }
431
+ /**
432
+ * Map a set bit in one of the four vendor-specific event registers.
433
+ * Vendor bits are not standardized and many are informational rather
434
+ * than faults, so the default returns `undefined` and no error code is
435
+ * emitted. Override in a vendor-specific subclass to opt specific bits
436
+ * in as errors.
437
+ */
438
+ mapVendorEventBit(_registerIndex, _bit) {
439
+ return undefined;
440
+ }
441
+ hasErrorSetChanged(newCodeIds) {
442
+ const prev = this.errorState.activeCodes;
443
+ if (prev.length !== newCodeIds.length)
444
+ return true;
445
+ for (let i = 0; i < prev.length; i++) {
446
+ if (prev[i] !== newCodeIds[i])
447
+ return true;
448
+ }
449
+ return false;
450
+ }
451
+ buildStatusMessage(status, errorCodes, timestamp) {
452
+ return {
453
+ id: randomUUID(),
454
+ message: EnyoDataBusMessageEnum.ApplianceStateUpdateV1,
455
+ type: 'message',
456
+ source: EnyoSourceEnum.Device,
457
+ applianceId: this.applianceId,
458
+ timestampIso: timestamp.toISOString(),
459
+ data: {
460
+ status,
461
+ errorCodes,
462
+ }
463
+ };
464
+ }
465
+ storageKey() {
466
+ return `sunspec-inverter-error-state-${this.applianceId}`;
467
+ }
468
+ async loadErrorState() {
469
+ try {
470
+ this.storage = this.energyApp.useStorage();
471
+ const loaded = await this.storage.load(this.storageKey());
472
+ if (loaded) {
473
+ this.errorState = loaded;
474
+ console.log(`Inverter ${this.applianceId}: loaded persisted error state (lastStatus=${loaded.lastStatus}, codes=[${loaded.activeCodes.join(', ')}])`);
475
+ }
476
+ }
477
+ catch (error) {
478
+ console.error(`Inverter ${this.applianceId}: failed to load persisted error state: ${error}`);
479
+ }
480
+ }
481
+ async persistErrorState() {
482
+ if (!this.storage)
483
+ return;
484
+ try {
485
+ await this.storage.save(this.storageKey(), this.errorState);
486
+ }
487
+ catch (error) {
488
+ console.error(`Inverter ${this.applianceId}: failed to persist error state: ${error}`);
489
+ }
490
+ }
491
+ async detectAndEmitStatusTransition(data, timestamp) {
492
+ if (!this.applianceId)
493
+ return undefined;
494
+ const { codes, codeIds } = this.decodeActiveErrors(data);
495
+ const recoveringFromConnectionLoss = this.errorState.lastStatus === 'connection_lost';
496
+ if (!recoveringFromConnectionLoss && !this.hasErrorSetChanged(codeIds)) {
497
+ return undefined;
498
+ }
499
+ const newStatus = codeIds.length === 0
500
+ ? EnyoApplianceStatusEnum.Healthy
501
+ : EnyoApplianceStatusEnum.Faulted;
502
+ this.errorState = {
503
+ evt1: data.events,
504
+ evt2: data.events2,
505
+ evtVnd1: data.vendorEvents1,
506
+ evtVnd2: data.vendorEvents2,
507
+ evtVnd3: data.vendorEvents3,
508
+ evtVnd4: data.vendorEvents4,
509
+ activeCodes: codeIds,
510
+ lastStatus: newStatus === EnyoApplianceStatusEnum.Healthy ? 'healthy' : 'faulted',
511
+ };
512
+ await this.persistErrorState();
513
+ console.log(`Inverter ${this.applianceId}: status transition -> ${newStatus} (codes=[${codeIds.join(', ')}])`);
514
+ return this.buildStatusMessage(newStatus, codes, timestamp);
515
+ }
516
+ async onConnectionFailure(consecutiveFailures) {
517
+ if (!this.applianceId || !this.dataBus)
518
+ return;
519
+ if (consecutiveFailures < SunspecInverter.CONNECTION_FAULT_THRESHOLD)
520
+ return;
521
+ if (this.errorState.lastStatus === 'connection_lost')
522
+ return;
523
+ const errorCodes = [{
524
+ code: SUNSPEC_CONNECTION_LOST_CODE,
525
+ messages: [
526
+ { language: 'en', message: 'Modbus connection to inverter lost' },
527
+ { language: 'de', message: 'Modbus-Verbindung zum Wechselrichter verloren' },
528
+ ]
529
+ }];
530
+ const message = this.buildStatusMessage(EnyoApplianceStatusEnum.Faulted, errorCodes, new Date());
531
+ this.errorState = {
532
+ activeCodes: [SUNSPEC_CONNECTION_LOST_CODE],
533
+ lastStatus: 'connection_lost',
534
+ };
535
+ await this.persistErrorState();
536
+ console.log(`Inverter ${this.applianceId}: emitting faulted (${SUNSPEC_CONNECTION_LOST_CODE}) after ${consecutiveFailures} consecutive reconnect failures`);
537
+ this.dataBus.sendMessage([message]);
538
+ }
285
539
  mapOperatingState(state) {
286
540
  if (!state)
287
541
  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.50";
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.50';
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.50",
4
4
  "description": "enyo Energy Sunspec SDK",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",