@enyo-energy/sunspec-sdk 0.0.28 → 0.0.30

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.
@@ -1,7 +1,7 @@
1
1
  import { SunspecBatteryChargeState, SunspecModelId, SunspecMPPTOperatingState, SunspecStorageMode } from "./sunspec-interfaces.js";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { EnyoApplianceConnectionType, EnyoApplianceStateEnum, EnyoApplianceTopologyFeatureEnum, EnyoApplianceTypeEnum } from "@enyo-energy/energy-app-sdk/dist/types/enyo-appliance.js";
4
- import { EnyoBatteryStateEnum, EnyoDataBusMessageEnum, EnyoInverterStateEnum, EnyoStringStateEnum } from "@enyo-energy/energy-app-sdk/dist/types/enyo-data-bus-value.js";
4
+ import { EnyoBatteryStateEnum, EnyoCommandAcknowledgeAnswerEnum, EnyoDataBusMessageEnum, EnyoInverterStateEnum, EnyoStringStateEnum } from "@enyo-energy/energy-app-sdk/dist/types/enyo-data-bus-value.js";
5
5
  import { EnyoSourceEnum } from "@enyo-energy/energy-app-sdk/dist/types/enyo-source.enum.js";
6
6
  import { EnyoMeterApplianceAvailableFeaturesEnum } from "@enyo-energy/energy-app-sdk/dist/types/enyo-meter-appliance.js";
7
7
  import { EnyoBatteryFeature, EnyoBatteryStorageMode } from "@enyo-energy/energy-app-sdk/dist/types/enyo-battery-appliance.js";
@@ -28,15 +28,19 @@ export class BaseSunspecDevice {
28
28
  sunspecClient;
29
29
  applianceManager;
30
30
  unitId;
31
+ port;
32
+ baseAddress;
31
33
  applianceId;
32
34
  lastUpdateTime = 0;
33
- constructor(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId = 1) {
35
+ constructor(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId = 1, port = 502, baseAddress = 40000) {
34
36
  this.energyApp = energyApp;
35
37
  this.name = name;
36
38
  this.networkDevice = networkDevice;
37
39
  this.sunspecClient = sunspecClient;
38
40
  this.applianceManager = applianceManager;
39
41
  this.unitId = unitId;
42
+ this.port = port;
43
+ this.baseAddress = baseAddress;
40
44
  }
41
45
  /**
42
46
  * Check if the device is connected
@@ -52,8 +56,10 @@ export class BaseSunspecDevice {
52
56
  */
53
57
  async ensureConnected() {
54
58
  if (!this.sunspecClient.isConnected()) {
55
- await this.sunspecClient.connect(this.networkDevice.ipAddress, 502, this.unitId);
56
- await this.sunspecClient.discoverModels();
59
+ await this.sunspecClient.connect(this.networkDevice.hostname, // primary
60
+ this.port, this.unitId, this.networkDevice.ipAddress // secondary fallback
61
+ );
62
+ await this.sunspecClient.discoverModels(this.baseAddress);
57
63
  }
58
64
  }
59
65
  }
@@ -64,8 +70,10 @@ export class SunspecInverter extends BaseSunspecDevice {
64
70
  async connect() {
65
71
  // Ensure Sunspec client is connected
66
72
  if (!this.sunspecClient.isConnected()) {
67
- await this.sunspecClient.connect(this.networkDevice.ipAddress, 502, this.unitId);
68
- await this.sunspecClient.discoverModels();
73
+ await this.sunspecClient.connect(this.networkDevice.hostname, // primary
74
+ this.port, this.unitId, this.networkDevice.ipAddress // secondary fallback
75
+ );
76
+ await this.sunspecClient.discoverModels(this.baseAddress);
69
77
  }
70
78
  // Get device info from common block
71
79
  const commonData = await this.sunspecClient.readCommonBlock();
@@ -236,6 +244,8 @@ export class SunspecInverter extends BaseSunspecDevice {
236
244
  * Sunspec Battery implementation
237
245
  */
238
246
  export class SunspecBattery extends BaseSunspecDevice {
247
+ dataBusListenerId;
248
+ dataBus;
239
249
  /**
240
250
  * Connect to the battery and create/update the appliance
241
251
  */
@@ -279,12 +289,14 @@ export class SunspecBattery extends BaseSunspecDevice {
279
289
  }
280
290
  });
281
291
  console.log(`Sunspec Battery connected: ${this.networkDevice.hostname} (${this.applianceId})`);
292
+ this.startDataBusListening();
282
293
  }
283
294
  catch (error) {
284
295
  console.error(`Failed to create battery appliance: ${error}`);
285
296
  }
286
297
  }
287
298
  async disconnect() {
299
+ this.stopDataBusListening();
288
300
  if (this.applianceId) {
289
301
  await this.applianceManager.updateApplianceState(this.applianceId, EnyoApplianceConnectionType.Connector, EnyoApplianceStateEnum.Offline);
290
302
  }
@@ -553,6 +565,155 @@ export class SunspecBattery extends BaseSunspecDevice {
553
565
  }
554
566
  return controls.chaGriSet === 1; // 1 = GRID, 0 = PV
555
567
  }
568
+ /**
569
+ * Start listening for storage commands on the data bus.
570
+ * Idempotent — does nothing if already listening.
571
+ */
572
+ startDataBusListening() {
573
+ if (this.dataBusListenerId) {
574
+ return;
575
+ }
576
+ this.dataBus = this.energyApp.useDataBus();
577
+ this.dataBusListenerId = this.dataBus.listenForMessages([
578
+ EnyoDataBusMessageEnum.StartStorageGridChargeV1,
579
+ EnyoDataBusMessageEnum.StopStorageGridChargeV1,
580
+ EnyoDataBusMessageEnum.SetStorageDischargeLimitV1
581
+ ], (entry) => this.handleStorageCommand(entry));
582
+ console.log(`Battery ${this.applianceId}: started data bus listening (listener ${this.dataBusListenerId})`);
583
+ }
584
+ /**
585
+ * Stop listening for storage commands on the data bus.
586
+ */
587
+ stopDataBusListening() {
588
+ if (this.dataBusListenerId && this.dataBus) {
589
+ this.dataBus.unsubscribe(this.dataBusListenerId);
590
+ console.log(`Battery ${this.applianceId}: stopped data bus listening (listener ${this.dataBusListenerId})`);
591
+ }
592
+ this.dataBusListenerId = undefined;
593
+ this.dataBus = undefined;
594
+ }
595
+ handleStorageCommand(entry) {
596
+ if (entry.applianceId !== this.applianceId) {
597
+ return;
598
+ }
599
+ void (async () => {
600
+ try {
601
+ switch (entry.message) {
602
+ case EnyoDataBusMessageEnum.StartStorageGridChargeV1:
603
+ await this.handleStartGridCharge(entry);
604
+ break;
605
+ case EnyoDataBusMessageEnum.StopStorageGridChargeV1:
606
+ await this.handleStopGridCharge(entry);
607
+ break;
608
+ case EnyoDataBusMessageEnum.SetStorageDischargeLimitV1:
609
+ await this.handleSetDischargeLimit(entry);
610
+ break;
611
+ }
612
+ }
613
+ catch (error) {
614
+ console.error(`Battery ${this.applianceId}: error handling ${entry.message}:`, error);
615
+ }
616
+ })();
617
+ }
618
+ async handleStartGridCharge(msg) {
619
+ if (!this.isConnected() || !this.applianceId) {
620
+ this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, "Not connected");
621
+ return;
622
+ }
623
+ console.log(`Battery ${this.applianceId}: handling StartStorageGridChargeV1 (powerLimitW=${msg.data.powerLimitW})`);
624
+ // Read current state for logging and rollback
625
+ const controls = await this.getBatteryControls();
626
+ console.log(`Battery ${this.applianceId}: current state - chaGriSet=${controls?.chaGriSet}, wChaMax=${controls?.wChaMax}, storCtlMod=${controls?.storCtlMod}`);
627
+ const originalChaGriSet = controls?.chaGriSet;
628
+ const originalWChaMax = controls?.wChaMax;
629
+ // Step 1: Enable grid charging (Register 17: chaGriSet=GRID)
630
+ const step1 = await this.enableGridCharging(true);
631
+ if (!step1) {
632
+ this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to enable grid charging');
633
+ return;
634
+ }
635
+ // Step 2: Set charging power (Register 2: wChaMax)
636
+ const step2 = await this.setChargingPower(msg.data.powerLimitW);
637
+ if (!step2) {
638
+ // Rollback step 1
639
+ await this.writeBatteryControls({ chaGriSet: originalChaGriSet });
640
+ this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to set charging power');
641
+ return;
642
+ }
643
+ // Step 3: Set storage mode to CHARGE (Register 5: storCtlMod=0x0001)
644
+ const step3 = await this.setStorageMode(SunspecStorageMode.CHARGE);
645
+ if (!step3) {
646
+ // Rollback steps 1+2
647
+ await this.writeBatteryControls({ chaGriSet: originalChaGriSet, wChaMax: originalWChaMax });
648
+ this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to set storage mode to charge');
649
+ return;
650
+ }
651
+ this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Accepted);
652
+ }
653
+ async handleStopGridCharge(msg) {
654
+ if (!this.isConnected() || !this.applianceId) {
655
+ this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.NotSupported);
656
+ return;
657
+ }
658
+ console.log(`Battery ${this.applianceId}: handling StopStorageGridChargeV1`);
659
+ // Read current state for logging and rollback
660
+ const controls = await this.getBatteryControls();
661
+ console.log(`Battery ${this.applianceId}: current state - chaGriSet=${controls?.chaGriSet}, storCtlMod=${controls?.storCtlMod}`);
662
+ const originalChaGriSet = controls?.chaGriSet;
663
+ // Step 1: Disable grid charging (Register 17: chaGriSet=PV)
664
+ const step1 = await this.enableGridCharging(false);
665
+ if (!step1) {
666
+ this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to disable grid charging');
667
+ return;
668
+ }
669
+ // Step 2: Set storage mode to AUTO (Register 5: storCtlMod=0x0003)
670
+ const step2 = await this.setStorageMode(SunspecStorageMode.AUTO);
671
+ if (!step2) {
672
+ // Rollback step 1
673
+ await this.writeBatteryControls({ chaGriSet: originalChaGriSet });
674
+ this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to set storage mode to auto');
675
+ return;
676
+ }
677
+ this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Accepted);
678
+ }
679
+ async handleSetDischargeLimit(msg) {
680
+ if (!this.isConnected() || !this.applianceId) {
681
+ this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.NotSupported);
682
+ return;
683
+ }
684
+ console.log(`Battery ${this.applianceId}: handling SetStorageDischargeLimitV1 (dischargeLimitPercent=${msg.data.dischargeLimitPercent})`);
685
+ // Read current state for logging
686
+ const controls = await this.getBatteryControls();
687
+ console.log(`Battery ${this.applianceId}: current state - outWRte=${controls?.outWRte}`);
688
+ // Set discharge limit (Register 12: outWRte)
689
+ const success = await this.writeBatteryControls({ outWRte: msg.data.dischargeLimitPercent });
690
+ if (!success) {
691
+ this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to set discharge limit');
692
+ return;
693
+ }
694
+ this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Accepted);
695
+ }
696
+ sendCommandAcknowledge(messageId, acknowledgeMessage, answer, rejectionReason) {
697
+ if (!this.dataBus || !this.applianceId) {
698
+ return;
699
+ }
700
+ const ackMessage = {
701
+ id: randomUUID(),
702
+ message: EnyoDataBusMessageEnum.CommandAcknowledgeV1,
703
+ type: 'answer',
704
+ source: EnyoSourceEnum.Device,
705
+ applianceId: this.applianceId,
706
+ timestampIso: new Date().toISOString(),
707
+ data: {
708
+ messageId,
709
+ acknowledgeMessage,
710
+ answer,
711
+ rejectionReason
712
+ }
713
+ };
714
+ console.log(`Battery ${this.applianceId}: sending ${answer} for ${acknowledgeMessage} (messageId=${messageId}${rejectionReason ? `, reason=${rejectionReason}` : ''})`);
715
+ this.dataBus.sendMessage([ackMessage]);
716
+ }
556
717
  }
557
718
  /**
558
719
  * Sunspec Meter implementation
@@ -1,6 +1,16 @@
1
1
  /**
2
2
  * SunSpec block interfaces with block numbers
3
3
  */
4
+ /**
5
+ * Configuration for connection retry with exponential backoff
6
+ */
7
+ export interface IRetryConfig {
8
+ initialDelayMs: number;
9
+ maxDelayMs: number;
10
+ backoffFactor: number;
11
+ maxAttempts: number;
12
+ }
13
+ export declare const DEFAULT_RETRY_CONFIG: IRetryConfig;
4
14
  /**
5
15
  * Base interface for all SunSpec blocks
6
16
  */
@@ -1,6 +1,12 @@
1
1
  /**
2
2
  * SunSpec block interfaces with block numbers
3
3
  */
4
+ export const DEFAULT_RETRY_CONFIG = {
5
+ initialDelayMs: 1000,
6
+ maxDelayMs: 30000,
7
+ backoffFactor: 1.5,
8
+ maxAttempts: 10
9
+ };
4
10
  /**
5
11
  * Common Sunspec Model IDs
6
12
  */
@@ -16,7 +16,8 @@
16
16
  * - pad: 0x8000 (always returns this value)
17
17
  * - string: all registers 0x0000 (NULL)
18
18
  */
19
- import { type SunspecInverterControls, type SunspecInverterData, type SunspecInverterSettings, type SunspecMeterData, type SunspecModel, type SunspecMPPTData, type SunspecBatteryData, type SunspecBatteryControls, SunspecStorageMode } from "./sunspec-interfaces.js";
19
+ import { type SunspecInverterControls, type SunspecInverterData, type SunspecInverterSettings, type SunspecMeterData, type SunspecModel, type SunspecMPPTData, type SunspecBatteryData, type SunspecBatteryControls, SunspecStorageMode, type IRetryConfig } from "./sunspec-interfaces.js";
20
+ import { ConnectionRetryManager } from "./connection-retry-manager.js";
20
21
  import { IConnectionHealth } from "@enyo-energy/energy-app-sdk/dist/implementations/modbus/interfaces.js";
21
22
  import { EnergyApp } from "@enyo-energy/energy-app-sdk";
22
23
  export declare class SunspecModbusClient {
@@ -24,23 +25,57 @@ export declare class SunspecModbusClient {
24
25
  private modbusClient;
25
26
  private discoveredModels;
26
27
  private connected;
27
- private baseAddress;
28
28
  private connectionHealth;
29
29
  private faultTolerantReader;
30
30
  private modbusDataTypeConverter;
31
- constructor(energyApp: EnergyApp);
31
+ private connectionParams;
32
+ private retryManager;
33
+ private autoReconnectEnabled;
34
+ constructor(energyApp: EnergyApp, retryConfig?: Partial<IRetryConfig>);
32
35
  /**
33
36
  * Connect to Modbus device
37
+ * @param host Primary host (hostname) to connect to
38
+ * @param port Modbus port (default 502)
39
+ * @param unitId Modbus unit ID (default 1)
40
+ * @param secondaryHost Optional secondary host (ipAddress) for fallback during reconnection
34
41
  */
35
- connect(host: string, port?: number, unitId?: number): Promise<void>;
42
+ connect(host: string, port?: number, unitId?: number, secondaryHost?: string): Promise<void>;
36
43
  /**
37
44
  * Disconnect from Modbus device
38
45
  */
39
46
  disconnect(): Promise<void>;
47
+ /**
48
+ * Reconnect using stored connection parameters
49
+ * First tries primaryHost (hostname), then falls back to secondaryHost (ipAddress) if available
50
+ * Returns true if reconnection was successful, false otherwise
51
+ */
52
+ reconnect(): Promise<boolean>;
53
+ /**
54
+ * Attempt to establish a connection to a specific host
55
+ * Returns true if successful, false otherwise
56
+ */
57
+ private attemptConnection;
58
+ /**
59
+ * Check connection health and trigger automatic reconnection if unhealthy
60
+ * Returns true if connection is healthy or was successfully restored
61
+ */
62
+ ensureHealthyConnection(): Promise<boolean>;
63
+ /**
64
+ * Enable or disable automatic reconnection
65
+ */
66
+ setAutoReconnect(enabled: boolean): void;
67
+ /**
68
+ * Check if auto-reconnect is enabled
69
+ */
70
+ isAutoReconnectEnabled(): boolean;
71
+ /**
72
+ * Get the retry manager for advanced configuration
73
+ */
74
+ getRetryManager(): ConnectionRetryManager;
40
75
  /**
41
76
  * Detect the base address and addressing mode (0-based or 1-based) for SunSpec
42
77
  */
43
- detectSunspecBaseAddress(): Promise<{
78
+ detectSunspecBaseAddress(customBaseAddress?: number): Promise<{
44
79
  baseAddress: number;
45
80
  isZeroBased: boolean;
46
81
  nextAddress: number;
@@ -49,7 +84,7 @@ export declare class SunspecModbusClient {
49
84
  * Discover all available Sunspec models
50
85
  * Automatically detects base address (40000 or 40001) and scans from there
51
86
  */
52
- discoverModels(): Promise<Map<number, SunspecModel>>;
87
+ discoverModels(customBaseAddress?: number): Promise<Map<number, SunspecModel>>;
53
88
  /**
54
89
  * Find a specific model by ID
55
90
  */
@@ -17,6 +17,7 @@
17
17
  * - string: all registers 0x0000 (NULL)
18
18
  */
19
19
  import { SunspecModelId, SunspecBatteryChargeState, SunspecStorageControlMode, SunspecChargeSource, SunspecStorageMode } from "./sunspec-interfaces.js";
20
+ import { ConnectionRetryManager } from "./connection-retry-manager.js";
20
21
  import { EnergyAppModbusConnectionHealth } from "@enyo-energy/energy-app-sdk/dist/implementations/modbus/EnergyAppModbusConnectionHealth.js";
21
22
  import { EnergyAppModbusFaultTolerantReader } from "@enyo-energy/energy-app-sdk/dist/implementations/modbus/EnergyAppModbusFaultTolerantReader.js";
22
23
  import { EnergyAppModbusDataTypeConverter } from "@enyo-energy/energy-app-sdk/dist/implementations/modbus/EnergyAppModbusDataTypeConverter.js";
@@ -25,22 +26,31 @@ export class SunspecModbusClient {
25
26
  modbusClient = null;
26
27
  discoveredModels = new Map();
27
28
  connected = false;
28
- baseAddress = 40001;
29
29
  connectionHealth;
30
30
  faultTolerantReader = null;
31
31
  modbusDataTypeConverter;
32
- constructor(energyApp) {
32
+ connectionParams = null;
33
+ retryManager;
34
+ autoReconnectEnabled = true;
35
+ constructor(energyApp, retryConfig) {
33
36
  this.energyApp = energyApp;
34
37
  this.connectionHealth = new EnergyAppModbusConnectionHealth();
35
38
  this.modbusDataTypeConverter = new EnergyAppModbusDataTypeConverter();
39
+ this.retryManager = new ConnectionRetryManager(retryConfig);
36
40
  }
37
41
  /**
38
42
  * Connect to Modbus device
43
+ * @param host Primary host (hostname) to connect to
44
+ * @param port Modbus port (default 502)
45
+ * @param unitId Modbus unit ID (default 1)
46
+ * @param secondaryHost Optional secondary host (ipAddress) for fallback during reconnection
39
47
  */
40
- async connect(host, port = 502, unitId = 1) {
48
+ async connect(host, port = 502, unitId = 1, secondaryHost) {
41
49
  if (this.connected) {
42
50
  await this.disconnect();
43
51
  }
52
+ // Store connection parameters for potential reconnection
53
+ this.connectionParams = { primaryHost: host, secondaryHost, port, unitId };
44
54
  this.modbusClient = await this.energyApp.useModbus().connect({
45
55
  host,
46
56
  port,
@@ -53,12 +63,15 @@ export class SunspecModbusClient {
53
63
  }
54
64
  this.connected = true;
55
65
  this.connectionHealth.recordSuccess();
66
+ this.retryManager.reset();
56
67
  console.log(`Connected to Sunspec device at ${host}:${port} unit ${unitId}`);
57
68
  }
58
69
  /**
59
70
  * Disconnect from Modbus device
60
71
  */
61
72
  async disconnect() {
73
+ // Cancel any pending retry attempts
74
+ this.retryManager.cancelPendingRetry();
62
75
  if (this.modbusClient && this.connected) {
63
76
  await this.modbusClient.disconnect();
64
77
  this.modbusClient = null;
@@ -66,20 +79,190 @@ export class SunspecModbusClient {
66
79
  this.connected = false;
67
80
  this.discoveredModels.clear();
68
81
  }
82
+ // Clear stored connection params
83
+ this.connectionParams = null;
84
+ }
85
+ /**
86
+ * Reconnect using stored connection parameters
87
+ * First tries primaryHost (hostname), then falls back to secondaryHost (ipAddress) if available
88
+ * Returns true if reconnection was successful, false otherwise
89
+ */
90
+ async reconnect() {
91
+ if (!this.connectionParams) {
92
+ console.error('Cannot reconnect: no connection parameters stored. Call connect() first.');
93
+ return false;
94
+ }
95
+ const { primaryHost, secondaryHost, port, unitId } = this.connectionParams;
96
+ // Try primary host first
97
+ console.log(`Attempting to reconnect to primary host ${primaryHost}:${port} unit ${unitId}...`);
98
+ const primarySuccess = await this.attemptConnection(primaryHost, port, unitId);
99
+ if (primarySuccess) {
100
+ console.log(`Successfully reconnected to primary host ${primaryHost}:${port} unit ${unitId}`);
101
+ return true;
102
+ }
103
+ // If primary failed and secondary is available, try secondary
104
+ if (secondaryHost && secondaryHost !== primaryHost) {
105
+ console.log(`Primary host failed, attempting secondary host ${secondaryHost}:${port} unit ${unitId}...`);
106
+ const secondarySuccess = await this.attemptConnection(secondaryHost, port, unitId);
107
+ if (secondarySuccess) {
108
+ console.log(`Successfully reconnected to secondary host ${secondaryHost}:${port} unit ${unitId}`);
109
+ return true;
110
+ }
111
+ }
112
+ console.error(`Reconnection failed to all available hosts`);
113
+ this.connected = false;
114
+ return false;
115
+ }
116
+ /**
117
+ * Attempt to establish a connection to a specific host
118
+ * Returns true if successful, false otherwise
119
+ */
120
+ async attemptConnection(host, port, unitId) {
121
+ try {
122
+ // Disconnect existing connection if any
123
+ if (this.modbusClient) {
124
+ try {
125
+ await this.modbusClient.disconnect();
126
+ }
127
+ catch (e) {
128
+ // Ignore disconnect errors during reconnection
129
+ }
130
+ this.modbusClient = null;
131
+ this.faultTolerantReader = null;
132
+ }
133
+ // Attempt connection
134
+ this.modbusClient = await this.energyApp.useModbus().connect({
135
+ host,
136
+ port,
137
+ unitId,
138
+ timeout: 5000
139
+ });
140
+ // Create fault-tolerant reader with connection health monitoring
141
+ if (this.modbusClient) {
142
+ this.faultTolerantReader = new EnergyAppModbusFaultTolerantReader(this.modbusClient, this.connectionHealth);
143
+ }
144
+ this.connected = true;
145
+ this.connectionHealth.recordSuccess();
146
+ return true;
147
+ }
148
+ catch (error) {
149
+ console.error(`Connection attempt to ${host}:${port} failed: ${error}`);
150
+ return false;
151
+ }
152
+ }
153
+ /**
154
+ * Check connection health and trigger automatic reconnection if unhealthy
155
+ * Returns true if connection is healthy or was successfully restored
156
+ */
157
+ async ensureHealthyConnection() {
158
+ // If already healthy, return immediately
159
+ if (this.isHealthy()) {
160
+ return true;
161
+ }
162
+ // If no connection params, we can't reconnect
163
+ if (!this.connectionParams) {
164
+ console.error('Connection unhealthy and no connection parameters stored for reconnection.');
165
+ return false;
166
+ }
167
+ // If auto-reconnect is disabled, just report the status
168
+ if (!this.autoReconnectEnabled) {
169
+ console.log('Connection unhealthy but auto-reconnect is disabled.');
170
+ return false;
171
+ }
172
+ // If a retry is already in progress, don't start another
173
+ if (this.retryManager.isRetryInProgress()) {
174
+ console.log('Retry already in progress...');
175
+ return false;
176
+ }
177
+ console.log('Connection unhealthy, initiating reconnection with exponential backoff...');
178
+ // Attempt reconnection with exponential backoff
179
+ let success = false;
180
+ while (this.retryManager.shouldRetry() && !success) {
181
+ success = await this.retryManager.scheduleRetry(() => this.reconnect());
182
+ if (success) {
183
+ // Re-discover models after successful reconnection
184
+ try {
185
+ await this.discoverModels();
186
+ console.log('Models re-discovered after reconnection.');
187
+ }
188
+ catch (e) {
189
+ console.warn('Failed to re-discover models after reconnection:', e);
190
+ }
191
+ return true;
192
+ }
193
+ }
194
+ if (!success) {
195
+ console.error('Failed to restore healthy connection after all retry attempts.');
196
+ }
197
+ return success;
198
+ }
199
+ /**
200
+ * Enable or disable automatic reconnection
201
+ */
202
+ setAutoReconnect(enabled) {
203
+ this.autoReconnectEnabled = enabled;
204
+ console.log(`Auto-reconnect ${enabled ? 'enabled' : 'disabled'}`);
205
+ if (!enabled) {
206
+ this.retryManager.cancelPendingRetry();
207
+ }
208
+ }
209
+ /**
210
+ * Check if auto-reconnect is enabled
211
+ */
212
+ isAutoReconnectEnabled() {
213
+ return this.autoReconnectEnabled;
214
+ }
215
+ /**
216
+ * Get the retry manager for advanced configuration
217
+ */
218
+ getRetryManager() {
219
+ return this.retryManager;
69
220
  }
70
221
  /**
71
222
  * Detect the base address and addressing mode (0-based or 1-based) for SunSpec
72
223
  */
73
- async detectSunspecBaseAddress() {
224
+ async detectSunspecBaseAddress(customBaseAddress) {
74
225
  if (!this.modbusClient) {
75
226
  throw new Error('Modbus client not initialized');
76
227
  }
228
+ // If custom base address provided, try it first (1-based, then 0-based variant)
229
+ if (customBaseAddress !== undefined) {
230
+ // Try 1-based at custom address (customBaseAddress + 1)
231
+ try {
232
+ const sunspecId = await this.modbusClient.readRegisterStringValue(customBaseAddress + 1, 2);
233
+ if (sunspecId.includes('SunS')) {
234
+ console.log(`Detected 1-based addressing mode (base address: ${customBaseAddress + 1})`);
235
+ return {
236
+ baseAddress: customBaseAddress + 1,
237
+ isZeroBased: false,
238
+ nextAddress: customBaseAddress + 3
239
+ };
240
+ }
241
+ }
242
+ catch (error) {
243
+ console.debug(`Could not read SunS at ${customBaseAddress + 1}:`, error);
244
+ }
245
+ // Try 0-based at custom address
246
+ try {
247
+ const sunspecId = await this.modbusClient.readRegisterStringValue(customBaseAddress, 2);
248
+ if (sunspecId.includes('SunS')) {
249
+ console.log(`Detected 0-based addressing mode (base address: ${customBaseAddress})`);
250
+ return {
251
+ baseAddress: customBaseAddress,
252
+ isZeroBased: true,
253
+ nextAddress: customBaseAddress + 2
254
+ };
255
+ }
256
+ }
257
+ catch (error) {
258
+ console.debug(`Could not read SunS at ${customBaseAddress}:`, error);
259
+ }
260
+ }
77
261
  // Try 1-based addressing first (most common)
78
262
  try {
79
263
  const sunspecId = await this.modbusClient.readRegisterStringValue(40001, 2);
80
264
  if (sunspecId.includes('SunS')) {
81
265
  console.log('Detected 1-based addressing mode (base address: 40001)');
82
- this.baseAddress = 40001;
83
266
  return {
84
267
  baseAddress: 40001,
85
268
  isZeroBased: false,
@@ -95,7 +278,6 @@ export class SunspecModbusClient {
95
278
  const sunspecId = await this.modbusClient.readRegisterStringValue(40000, 2);
96
279
  if (sunspecId.includes('SunS')) {
97
280
  console.log('Detected 0-based addressing mode (base address: 40000)');
98
- this.baseAddress = 40000;
99
281
  return {
100
282
  baseAddress: 40000,
101
283
  isZeroBased: true,
@@ -106,13 +288,16 @@ export class SunspecModbusClient {
106
288
  catch (error) {
107
289
  console.debug('Could not read SunS at 40000:', error);
108
290
  }
109
- throw new Error('Device is not SunSpec compliant - "SunS" identifier not found at addresses 40000 or 40001');
291
+ const addressesChecked = customBaseAddress !== undefined
292
+ ? `${customBaseAddress}, ${customBaseAddress + 1}, 40000, or 40001`
293
+ : '40000 or 40001';
294
+ throw new Error(`Device is not SunSpec compliant - "SunS" identifier not found at addresses ${addressesChecked}`);
110
295
  }
111
296
  /**
112
297
  * Discover all available Sunspec models
113
298
  * Automatically detects base address (40000 or 40001) and scans from there
114
299
  */
115
- async discoverModels() {
300
+ async discoverModels(customBaseAddress) {
116
301
  if (!this.connected) {
117
302
  throw new Error('Not connected to Modbus device');
118
303
  }
@@ -122,7 +307,7 @@ export class SunspecModbusClient {
122
307
  console.log('Starting Sunspec model discovery...');
123
308
  try {
124
309
  // Detect the base address and addressing mode
125
- const addressInfo = await this.detectSunspecBaseAddress();
310
+ const addressInfo = await this.detectSunspecBaseAddress(customBaseAddress);
126
311
  currentAddress = addressInfo.nextAddress;
127
312
  while (currentAddress < maxAddress) {
128
313
  // Read model ID and length
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.28";
8
+ export declare const SDK_VERSION = "0.0.30";
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.28';
8
+ export const SDK_VERSION = '0.0.30';
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.28",
3
+ "version": "0.0.30",
4
4
  "description": "enyo Energy Sunspec SDK",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -37,7 +37,7 @@
37
37
  "typescript": "^5.8.3"
38
38
  },
39
39
  "dependencies": {
40
- "@enyo-energy/energy-app-sdk": "^0.0.52"
40
+ "@enyo-energy/energy-app-sdk": "^0.0.60"
41
41
  },
42
42
  "volta": {
43
43
  "node": "22.17.0"