@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.
- package/dist/cjs/connection-retry-manager.cjs +125 -0
- package/dist/cjs/connection-retry-manager.d.cts +53 -0
- package/dist/cjs/index.cjs +3 -1
- package/dist/cjs/index.d.cts +1 -0
- package/dist/cjs/sunspec-devices.cjs +166 -5
- package/dist/cjs/sunspec-devices.d.cts +19 -1
- package/dist/cjs/sunspec-interfaces.cjs +7 -1
- package/dist/cjs/sunspec-interfaces.d.cts +10 -0
- package/dist/cjs/sunspec-modbus-client.cjs +194 -9
- package/dist/cjs/sunspec-modbus-client.d.cts +41 -6
- package/dist/cjs/version.cjs +1 -1
- package/dist/cjs/version.d.cts +1 -1
- package/dist/connection-retry-manager.d.ts +53 -0
- package/dist/connection-retry-manager.js +121 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/sunspec-devices.d.ts +19 -1
- package/dist/sunspec-devices.js +167 -6
- package/dist/sunspec-interfaces.d.ts +10 -0
- package/dist/sunspec-interfaces.js +6 -0
- package/dist/sunspec-modbus-client.d.ts +41 -6
- package/dist/sunspec-modbus-client.js +194 -9
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
package/dist/sunspec-devices.js
CHANGED
|
@@ -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.
|
|
56
|
-
|
|
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.
|
|
68
|
-
|
|
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
|
*/
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
package/dist/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@enyo-energy/sunspec-sdk",
|
|
3
|
-
"version": "0.0.
|
|
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.
|
|
40
|
+
"@enyo-energy/energy-app-sdk": "^0.0.60"
|
|
41
41
|
},
|
|
42
42
|
"volta": {
|
|
43
43
|
"node": "22.17.0"
|