@enyo-energy/sunspec-sdk 0.0.32 → 0.0.34
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 +63 -80
- package/dist/cjs/connection-retry-manager.d.cts +32 -27
- package/dist/cjs/sunspec-devices.cjs +178 -51
- package/dist/cjs/sunspec-devices.d.cts +47 -6
- package/dist/cjs/sunspec-interfaces.cjs +28 -5
- package/dist/cjs/sunspec-interfaces.d.cts +18 -5
- package/dist/cjs/sunspec-modbus-client.cjs +61 -91
- package/dist/cjs/sunspec-modbus-client.d.cts +27 -28
- package/dist/cjs/version.cjs +1 -1
- package/dist/cjs/version.d.cts +1 -1
- package/dist/connection-retry-manager.d.ts +32 -27
- package/dist/connection-retry-manager.js +63 -80
- package/dist/sunspec-devices.d.ts +47 -6
- package/dist/sunspec-devices.js +178 -51
- package/dist/sunspec-interfaces.d.ts +18 -5
- package/dist/sunspec-interfaces.js +27 -4
- package/dist/sunspec-modbus-client.d.ts +27 -28
- package/dist/sunspec-modbus-client.js +62 -92
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
|
@@ -1,122 +1,105 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
* Connection Retry Manager with
|
|
3
|
+
* Connection Retry Manager with Tiered Schedule
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* Poll-based retry manager — no timers or setTimeout loops.
|
|
6
|
+
* Called on each readData() cycle; determines whether enough time has elapsed
|
|
7
|
+
* to attempt a reconnection based on the current retry phase.
|
|
8
|
+
*
|
|
9
|
+
* Default schedule:
|
|
10
|
+
* - Phase 1: every 10s for 1 minute
|
|
11
|
+
* - Phase 2: every 30s for 2 minutes
|
|
12
|
+
* - Phase 3: every 1m for 5 minutes
|
|
13
|
+
* - Phase 4: every 5m forever
|
|
7
14
|
*/
|
|
8
15
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
16
|
exports.ConnectionRetryManager = void 0;
|
|
10
17
|
const sunspec_interfaces_js_1 = require("./sunspec-interfaces.cjs");
|
|
11
18
|
class ConnectionRetryManager {
|
|
12
19
|
config;
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
20
|
+
disconnectedSince = null;
|
|
21
|
+
lastAttemptTime = 0;
|
|
22
|
+
attemptCount = 0;
|
|
16
23
|
constructor(config) {
|
|
17
|
-
this.config =
|
|
24
|
+
this.config = config ?? sunspec_interfaces_js_1.DEFAULT_RETRY_CONFIG;
|
|
18
25
|
}
|
|
19
26
|
/**
|
|
20
|
-
*
|
|
21
|
-
* Formula: min(initialDelayMs * backoffFactor^attempt, maxDelayMs)
|
|
27
|
+
* Mark the connection as disconnected. Sets the timestamp if not already set.
|
|
22
28
|
*/
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
29
|
+
markDisconnected() {
|
|
30
|
+
if (this.disconnectedSince === null) {
|
|
31
|
+
this.disconnectedSince = Date.now();
|
|
32
|
+
}
|
|
26
33
|
}
|
|
27
34
|
/**
|
|
28
|
-
* Reset
|
|
35
|
+
* Reset all retry state (call on successful reconnect).
|
|
29
36
|
*/
|
|
30
37
|
reset() {
|
|
31
|
-
this.
|
|
32
|
-
this.
|
|
33
|
-
this.
|
|
38
|
+
this.disconnectedSince = null;
|
|
39
|
+
this.lastAttemptTime = 0;
|
|
40
|
+
this.attemptCount = 0;
|
|
34
41
|
}
|
|
35
42
|
/**
|
|
36
|
-
*
|
|
37
|
-
* Returns true if we haven't exceeded maxAttempts or if maxAttempts is -1 (infinite)
|
|
43
|
+
* Determine which retry phase we're currently in based on elapsed time since disconnect.
|
|
38
44
|
*/
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
45
|
+
getCurrentPhase() {
|
|
46
|
+
const elapsed = this.getElapsedMs();
|
|
47
|
+
let cumulativeDuration = 0;
|
|
48
|
+
for (const phase of this.config.phases) {
|
|
49
|
+
if (phase.durationMs === 0) {
|
|
50
|
+
// This is the final "forever" phase
|
|
51
|
+
return phase;
|
|
52
|
+
}
|
|
53
|
+
cumulativeDuration += phase.durationMs;
|
|
54
|
+
if (elapsed < cumulativeDuration) {
|
|
55
|
+
return phase;
|
|
56
|
+
}
|
|
42
57
|
}
|
|
43
|
-
return
|
|
58
|
+
// Fallback: return last phase
|
|
59
|
+
return this.config.phases[this.config.phases.length - 1];
|
|
44
60
|
}
|
|
45
61
|
/**
|
|
46
|
-
*
|
|
62
|
+
* Check if we should attempt a reconnection now.
|
|
63
|
+
* Called on every readData() poll cycle.
|
|
47
64
|
*/
|
|
48
|
-
|
|
49
|
-
|
|
65
|
+
shouldAttemptNow() {
|
|
66
|
+
if (this.disconnectedSince === null) {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
const now = Date.now();
|
|
70
|
+
const phase = this.getCurrentPhase();
|
|
71
|
+
return (now - this.lastAttemptTime) >= phase.intervalMs;
|
|
50
72
|
}
|
|
51
73
|
/**
|
|
52
|
-
*
|
|
74
|
+
* Record that an attempt was just made.
|
|
53
75
|
*/
|
|
54
|
-
|
|
55
|
-
|
|
76
|
+
recordAttempt() {
|
|
77
|
+
this.lastAttemptTime = Date.now();
|
|
78
|
+
this.attemptCount++;
|
|
56
79
|
}
|
|
57
80
|
/**
|
|
58
|
-
*
|
|
81
|
+
* Get the number of reconnection attempts made.
|
|
59
82
|
*/
|
|
60
|
-
|
|
61
|
-
return this.
|
|
83
|
+
getAttemptCount() {
|
|
84
|
+
return this.attemptCount;
|
|
62
85
|
}
|
|
63
86
|
/**
|
|
64
|
-
*
|
|
65
|
-
* Returns a promise that resolves when the callback completes
|
|
87
|
+
* Get milliseconds elapsed since disconnect was first detected.
|
|
66
88
|
*/
|
|
67
|
-
|
|
68
|
-
if (
|
|
69
|
-
|
|
70
|
-
return false;
|
|
71
|
-
}
|
|
72
|
-
if (this.isRetrying) {
|
|
73
|
-
console.log('Retry already in progress, skipping duplicate request.');
|
|
74
|
-
return false;
|
|
89
|
+
getElapsedMs() {
|
|
90
|
+
if (this.disconnectedSince === null) {
|
|
91
|
+
return 0;
|
|
75
92
|
}
|
|
76
|
-
|
|
77
|
-
const delay = this.getNextDelay();
|
|
78
|
-
const attemptNumber = this.currentAttempt + 1;
|
|
79
|
-
const maxAttemptsStr = this.config.maxAttempts === -1 ? '∞' : this.config.maxAttempts.toString();
|
|
80
|
-
console.log(`Scheduling reconnection attempt ${attemptNumber}/${maxAttemptsStr} in ${delay}ms`);
|
|
81
|
-
return new Promise((resolve) => {
|
|
82
|
-
this.pendingRetryTimeout = setTimeout(async () => {
|
|
83
|
-
this.pendingRetryTimeout = null;
|
|
84
|
-
this.currentAttempt++;
|
|
85
|
-
try {
|
|
86
|
-
console.log(`Attempting reconnection (attempt ${attemptNumber}/${maxAttemptsStr})...`);
|
|
87
|
-
const success = await callback();
|
|
88
|
-
if (success) {
|
|
89
|
-
console.log('Reconnection successful!');
|
|
90
|
-
this.reset();
|
|
91
|
-
resolve(true);
|
|
92
|
-
}
|
|
93
|
-
else {
|
|
94
|
-
console.log('Reconnection attempt failed.');
|
|
95
|
-
this.isRetrying = false;
|
|
96
|
-
resolve(false);
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
catch (error) {
|
|
100
|
-
console.error(`Reconnection attempt failed with error: ${error}`);
|
|
101
|
-
this.isRetrying = false;
|
|
102
|
-
resolve(false);
|
|
103
|
-
}
|
|
104
|
-
}, delay);
|
|
105
|
-
});
|
|
93
|
+
return Date.now() - this.disconnectedSince;
|
|
106
94
|
}
|
|
107
95
|
/**
|
|
108
|
-
*
|
|
96
|
+
* Check if the manager is currently tracking a disconnection.
|
|
109
97
|
*/
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
clearTimeout(this.pendingRetryTimeout);
|
|
113
|
-
this.pendingRetryTimeout = null;
|
|
114
|
-
this.isRetrying = false;
|
|
115
|
-
console.log('Pending retry cancelled.');
|
|
116
|
-
}
|
|
98
|
+
isDisconnected() {
|
|
99
|
+
return this.disconnectedSince !== null;
|
|
117
100
|
}
|
|
118
101
|
/**
|
|
119
|
-
* Get the current configuration
|
|
102
|
+
* Get the current configuration.
|
|
120
103
|
*/
|
|
121
104
|
getConfig() {
|
|
122
105
|
return { ...this.config };
|
|
@@ -1,53 +1,58 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Connection Retry Manager with
|
|
2
|
+
* Connection Retry Manager with Tiered Schedule
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Poll-based retry manager — no timers or setTimeout loops.
|
|
5
|
+
* Called on each readData() cycle; determines whether enough time has elapsed
|
|
6
|
+
* to attempt a reconnection based on the current retry phase.
|
|
7
|
+
*
|
|
8
|
+
* Default schedule:
|
|
9
|
+
* - Phase 1: every 10s for 1 minute
|
|
10
|
+
* - Phase 2: every 30s for 2 minutes
|
|
11
|
+
* - Phase 3: every 1m for 5 minutes
|
|
12
|
+
* - Phase 4: every 5m forever
|
|
6
13
|
*/
|
|
7
|
-
import { IRetryConfig } from './sunspec-interfaces.cjs';
|
|
14
|
+
import { IRetryConfig, IRetryPhase } from './sunspec-interfaces.cjs';
|
|
8
15
|
export declare class ConnectionRetryManager {
|
|
9
16
|
private config;
|
|
10
|
-
private
|
|
11
|
-
private
|
|
12
|
-
private
|
|
13
|
-
constructor(config?:
|
|
17
|
+
private disconnectedSince;
|
|
18
|
+
private lastAttemptTime;
|
|
19
|
+
private attemptCount;
|
|
20
|
+
constructor(config?: IRetryConfig);
|
|
14
21
|
/**
|
|
15
|
-
*
|
|
16
|
-
* Formula: min(initialDelayMs * backoffFactor^attempt, maxDelayMs)
|
|
22
|
+
* Mark the connection as disconnected. Sets the timestamp if not already set.
|
|
17
23
|
*/
|
|
18
|
-
|
|
24
|
+
markDisconnected(): void;
|
|
19
25
|
/**
|
|
20
|
-
* Reset
|
|
26
|
+
* Reset all retry state (call on successful reconnect).
|
|
21
27
|
*/
|
|
22
28
|
reset(): void;
|
|
23
29
|
/**
|
|
24
|
-
*
|
|
25
|
-
* Returns true if we haven't exceeded maxAttempts or if maxAttempts is -1 (infinite)
|
|
30
|
+
* Determine which retry phase we're currently in based on elapsed time since disconnect.
|
|
26
31
|
*/
|
|
27
|
-
|
|
32
|
+
getCurrentPhase(): IRetryPhase;
|
|
28
33
|
/**
|
|
29
|
-
*
|
|
34
|
+
* Check if we should attempt a reconnection now.
|
|
35
|
+
* Called on every readData() poll cycle.
|
|
30
36
|
*/
|
|
31
|
-
|
|
37
|
+
shouldAttemptNow(): boolean;
|
|
32
38
|
/**
|
|
33
|
-
*
|
|
39
|
+
* Record that an attempt was just made.
|
|
34
40
|
*/
|
|
35
|
-
|
|
41
|
+
recordAttempt(): void;
|
|
36
42
|
/**
|
|
37
|
-
*
|
|
43
|
+
* Get the number of reconnection attempts made.
|
|
38
44
|
*/
|
|
39
|
-
|
|
45
|
+
getAttemptCount(): number;
|
|
40
46
|
/**
|
|
41
|
-
*
|
|
42
|
-
* Returns a promise that resolves when the callback completes
|
|
47
|
+
* Get milliseconds elapsed since disconnect was first detected.
|
|
43
48
|
*/
|
|
44
|
-
|
|
49
|
+
getElapsedMs(): number;
|
|
45
50
|
/**
|
|
46
|
-
*
|
|
51
|
+
* Check if the manager is currently tracking a disconnection.
|
|
47
52
|
*/
|
|
48
|
-
|
|
53
|
+
isDisconnected(): boolean;
|
|
49
54
|
/**
|
|
50
|
-
* Get the current configuration
|
|
55
|
+
* Get the current configuration.
|
|
51
56
|
*/
|
|
52
57
|
getConfig(): IRetryConfig;
|
|
53
58
|
}
|
|
@@ -1,26 +1,16 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.SunspecMeter = exports.SunspecBattery = exports.SunspecInverter = exports.BaseSunspecDevice = void 0;
|
|
3
|
+
exports.SunspecMeter = exports.SunspecBattery = exports.SunspecInverter = exports.BaseSunspecDevice = exports.ENYO_DATA_BUS_SET_INVERTER_FEED_IN_LIMIT_V1 = void 0;
|
|
4
4
|
const sunspec_interfaces_js_1 = require("./sunspec-interfaces.cjs");
|
|
5
5
|
const node_crypto_1 = require("node:crypto");
|
|
6
6
|
const enyo_appliance_js_1 = require("@enyo-energy/energy-app-sdk/dist/types/enyo-appliance.js");
|
|
7
|
+
const connection_retry_manager_js_1 = require("./connection-retry-manager.cjs");
|
|
7
8
|
const enyo_data_bus_value_js_1 = require("@enyo-energy/energy-app-sdk/dist/types/enyo-data-bus-value.js");
|
|
8
9
|
const enyo_source_enum_js_1 = require("@enyo-energy/energy-app-sdk/dist/types/enyo-source.enum.js");
|
|
9
10
|
const enyo_meter_appliance_js_1 = require("@enyo-energy/energy-app-sdk/dist/types/enyo-meter-appliance.js");
|
|
10
11
|
const enyo_battery_appliance_js_1 = require("@enyo-energy/energy-app-sdk/dist/types/enyo-battery-appliance.js");
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
* Returns the discharge power in Watts (positive value), or 0 if no discharge.
|
|
14
|
-
*/
|
|
15
|
-
function extractBatteryDischargePowerFromMPPT(mpptDataList) {
|
|
16
|
-
let dischargePowerW = 0;
|
|
17
|
-
for (const mppt of mpptDataList) {
|
|
18
|
-
if (mppt.stringId === 'StDisCha 4' && mppt.dcPower !== undefined && mppt.dcPower > 0) {
|
|
19
|
-
dischargePowerW += mppt.dcPower;
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
return dischargePowerW;
|
|
23
|
-
}
|
|
12
|
+
// TODO: Remove once added to @enyo-energy/energy-app-sdk EnyoDataBusMessageEnum
|
|
13
|
+
exports.ENYO_DATA_BUS_SET_INVERTER_FEED_IN_LIMIT_V1 = 'SetInverterFeedInLimitV1';
|
|
24
14
|
/**
|
|
25
15
|
* Base abstract class for all Sunspec devices
|
|
26
16
|
*/
|
|
@@ -35,7 +25,10 @@ class BaseSunspecDevice {
|
|
|
35
25
|
baseAddress;
|
|
36
26
|
applianceId;
|
|
37
27
|
lastUpdateTime = 0;
|
|
38
|
-
|
|
28
|
+
dataBusListenerId;
|
|
29
|
+
dataBus;
|
|
30
|
+
retryManager;
|
|
31
|
+
constructor(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId = 1, port = 502, baseAddress = 40000, retryConfig) {
|
|
39
32
|
this.energyApp = energyApp;
|
|
40
33
|
this.name = name;
|
|
41
34
|
this.networkDevice = networkDevice;
|
|
@@ -44,6 +37,7 @@ class BaseSunspecDevice {
|
|
|
44
37
|
this.unitId = unitId;
|
|
45
38
|
this.port = port;
|
|
46
39
|
this.baseAddress = baseAddress;
|
|
40
|
+
this.retryManager = new connection_retry_manager_js_1.ConnectionRetryManager(retryConfig);
|
|
47
41
|
}
|
|
48
42
|
/**
|
|
49
43
|
* Check if the device is connected
|
|
@@ -65,12 +59,87 @@ class BaseSunspecDevice {
|
|
|
65
59
|
await this.sunspecClient.discoverModels(this.baseAddress);
|
|
66
60
|
}
|
|
67
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* Attempt a reconnection if the tiered retry schedule allows it.
|
|
64
|
+
* Called from readData() when the device is disconnected.
|
|
65
|
+
* Returns true if reconnection succeeded.
|
|
66
|
+
*/
|
|
67
|
+
async tryReconnect() {
|
|
68
|
+
this.retryManager.markDisconnected();
|
|
69
|
+
if (!this.retryManager.shouldAttemptNow()) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
this.retryManager.recordAttempt();
|
|
73
|
+
const phase = this.retryManager.getCurrentPhase();
|
|
74
|
+
const attempt = this.retryManager.getAttemptCount();
|
|
75
|
+
const elapsed = Math.round(this.retryManager.getElapsedMs() / 1000);
|
|
76
|
+
console.log(`${this.constructor.name} ${this.applianceId}: Reconnect attempt #${attempt} ` +
|
|
77
|
+
`(phase: ${phase.intervalMs / 1000}s interval, elapsed: ${elapsed}s)`);
|
|
78
|
+
try {
|
|
79
|
+
const success = await this.sunspecClient.reconnect();
|
|
80
|
+
if (success) {
|
|
81
|
+
// Re-discover models after reconnect
|
|
82
|
+
await this.sunspecClient.discoverModels(this.baseAddress);
|
|
83
|
+
this.retryManager.reset();
|
|
84
|
+
// Update appliance state to Connected
|
|
85
|
+
if (this.applianceId) {
|
|
86
|
+
await this.applianceManager.updateApplianceState(this.applianceId, enyo_appliance_js_1.EnyoApplianceConnectionType.Connector, enyo_appliance_js_1.EnyoApplianceStateEnum.Connected);
|
|
87
|
+
}
|
|
88
|
+
console.log(`${this.constructor.name} ${this.applianceId}: Reconnection successful after ${attempt} attempt(s)`);
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
console.error(`${this.constructor.name} ${this.applianceId}: Reconnect attempt #${attempt} failed: ${error}`);
|
|
94
|
+
}
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Mark the device as offline: update appliance state and start tracking disconnection.
|
|
99
|
+
*/
|
|
100
|
+
async markOffline() {
|
|
101
|
+
this.retryManager.markDisconnected();
|
|
102
|
+
if (this.applianceId) {
|
|
103
|
+
try {
|
|
104
|
+
await this.applianceManager.updateApplianceState(this.applianceId, enyo_appliance_js_1.EnyoApplianceConnectionType.Connector, enyo_appliance_js_1.EnyoApplianceStateEnum.Offline);
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
console.error(`${this.constructor.name} ${this.applianceId}: Failed to mark appliance offline: ${error}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
sendCommandAcknowledge(messageId, acknowledgeMessage, answer, rejectionReason) {
|
|
112
|
+
if (!this.dataBus || !this.applianceId) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const ackMessage = {
|
|
116
|
+
id: (0, node_crypto_1.randomUUID)(),
|
|
117
|
+
message: enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.CommandAcknowledgeV1,
|
|
118
|
+
type: 'answer',
|
|
119
|
+
source: enyo_source_enum_js_1.EnyoSourceEnum.Device,
|
|
120
|
+
applianceId: this.applianceId,
|
|
121
|
+
timestampIso: new Date().toISOString(),
|
|
122
|
+
data: {
|
|
123
|
+
messageId,
|
|
124
|
+
acknowledgeMessage: acknowledgeMessage,
|
|
125
|
+
answer,
|
|
126
|
+
rejectionReason
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
console.log(`${this.constructor.name} ${this.applianceId}: sending ${answer} for ${acknowledgeMessage} (messageId=${messageId}${rejectionReason ? `, reason=${rejectionReason}` : ''})`);
|
|
130
|
+
this.dataBus.sendMessage([ackMessage]);
|
|
131
|
+
}
|
|
68
132
|
}
|
|
69
133
|
exports.BaseSunspecDevice = BaseSunspecDevice;
|
|
70
134
|
/**
|
|
71
135
|
* Sunspec Inverter implementation using dynamic model discovery
|
|
72
136
|
*/
|
|
73
137
|
class SunspecInverter extends BaseSunspecDevice {
|
|
138
|
+
capabilities;
|
|
139
|
+
constructor(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId = 1, port = 502, baseAddress = 40000, capabilities = [], retryConfig) {
|
|
140
|
+
super(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId, port, baseAddress, retryConfig);
|
|
141
|
+
this.capabilities = capabilities;
|
|
142
|
+
}
|
|
74
143
|
async connect() {
|
|
75
144
|
// Ensure Sunspec client is connected
|
|
76
145
|
if (!this.sunspecClient.isConnected()) {
|
|
@@ -111,8 +180,10 @@ class SunspecInverter extends BaseSunspecDevice {
|
|
|
111
180
|
if (mpptModel) {
|
|
112
181
|
console.log(`MPPT model found for inverter ${this.networkDevice.hostname}`);
|
|
113
182
|
}
|
|
183
|
+
this.startDataBusListening();
|
|
114
184
|
}
|
|
115
185
|
async disconnect() {
|
|
186
|
+
this.stopDataBusListening();
|
|
116
187
|
if (this.applianceId) {
|
|
117
188
|
await this.applianceManager.updateApplianceState(this.applianceId, enyo_appliance_js_1.EnyoApplianceConnectionType.Connector, enyo_appliance_js_1.EnyoApplianceStateEnum.Offline);
|
|
118
189
|
}
|
|
@@ -123,7 +194,9 @@ class SunspecInverter extends BaseSunspecDevice {
|
|
|
123
194
|
}
|
|
124
195
|
async readData(clockId, resolution) {
|
|
125
196
|
if (!this.isConnected()) {
|
|
126
|
-
|
|
197
|
+
await this.tryReconnect();
|
|
198
|
+
if (!this.isConnected())
|
|
199
|
+
return [];
|
|
127
200
|
}
|
|
128
201
|
const messages = [];
|
|
129
202
|
const timestamp = new Date();
|
|
@@ -133,12 +206,9 @@ class SunspecInverter extends BaseSunspecDevice {
|
|
|
133
206
|
const mpptDataList = await this.sunspecClient.readAllMPPTData();
|
|
134
207
|
const inverterSettings = await this.sunspecClient.readInverterSettings();
|
|
135
208
|
const dcStrings = this.mapMPPTToStrings(mpptDataList);
|
|
136
|
-
// Calculate battery discharge power to subtract from AC power
|
|
137
|
-
const batteryDischargePowerW = extractBatteryDischargePowerFromMPPT(mpptDataList);
|
|
138
209
|
if (inverterData) {
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
console.log(`Got Battery Discharge power ${batteryDischargePowerW} and Inverter Power W ${totalAcPowerW} with pure PV Power ${purePvPowerW}`);
|
|
210
|
+
const pvPowerW = dcStrings.reduce((sum, s) => sum + (s.powerW || 0), 0);
|
|
211
|
+
console.log(`Got PV Power from DC strings: ${pvPowerW}W`);
|
|
142
212
|
const inverterMessage = {
|
|
143
213
|
id: (0, node_crypto_1.randomUUID)(),
|
|
144
214
|
message: enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.InverterValuesUpdateV1,
|
|
@@ -149,7 +219,7 @@ class SunspecInverter extends BaseSunspecDevice {
|
|
|
149
219
|
timestampIso: timestamp.toISOString(),
|
|
150
220
|
resolution,
|
|
151
221
|
data: {
|
|
152
|
-
pvPowerW
|
|
222
|
+
pvPowerW,
|
|
153
223
|
activePowerLimitationW: inverterSettings?.WMax,
|
|
154
224
|
state: this.mapOperatingState(inverterData.operatingState),
|
|
155
225
|
voltageL1: inverterData.voltageAN || 0,
|
|
@@ -173,6 +243,7 @@ class SunspecInverter extends BaseSunspecDevice {
|
|
|
173
243
|
}
|
|
174
244
|
catch (error) {
|
|
175
245
|
console.error(`Error updating inverter data: ${error}`);
|
|
246
|
+
await this.markOffline();
|
|
176
247
|
}
|
|
177
248
|
return messages;
|
|
178
249
|
}
|
|
@@ -243,14 +314,73 @@ class SunspecInverter extends BaseSunspecDevice {
|
|
|
243
314
|
mapDcStringToApplianceMetadata(mpptDataList) {
|
|
244
315
|
return mpptDataList.map(s => ({ index: s.index, name: s.name }));
|
|
245
316
|
}
|
|
317
|
+
/**
|
|
318
|
+
* Start listening for inverter commands on the data bus.
|
|
319
|
+
* Idempotent — does nothing if already listening.
|
|
320
|
+
*/
|
|
321
|
+
startDataBusListening() {
|
|
322
|
+
if (this.dataBusListenerId) {
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
this.dataBus = this.energyApp.useDataBus();
|
|
326
|
+
this.dataBusListenerId = this.dataBus.listenForMessages([exports.ENYO_DATA_BUS_SET_INVERTER_FEED_IN_LIMIT_V1], (entry) => this.handleInverterCommand(entry));
|
|
327
|
+
console.log(`Inverter ${this.applianceId}: started data bus listening (listener ${this.dataBusListenerId})`);
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Stop listening for inverter commands on the data bus.
|
|
331
|
+
*/
|
|
332
|
+
stopDataBusListening() {
|
|
333
|
+
if (this.dataBusListenerId && this.dataBus) {
|
|
334
|
+
this.dataBus.unsubscribe(this.dataBusListenerId);
|
|
335
|
+
console.log(`Inverter ${this.applianceId}: stopped data bus listening (listener ${this.dataBusListenerId})`);
|
|
336
|
+
}
|
|
337
|
+
this.dataBusListenerId = undefined;
|
|
338
|
+
this.dataBus = undefined;
|
|
339
|
+
}
|
|
340
|
+
handleInverterCommand(entry) {
|
|
341
|
+
if (entry.applianceId !== this.applianceId) {
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
void (async () => {
|
|
345
|
+
try {
|
|
346
|
+
if (entry.message === exports.ENYO_DATA_BUS_SET_INVERTER_FEED_IN_LIMIT_V1) {
|
|
347
|
+
await this.handleSetFeedInLimit(entry);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
catch (error) {
|
|
351
|
+
console.error(`Inverter ${this.applianceId}: error handling ${entry.message}:`, error);
|
|
352
|
+
}
|
|
353
|
+
})();
|
|
354
|
+
}
|
|
355
|
+
async handleSetFeedInLimit(msg) {
|
|
356
|
+
// Check capability
|
|
357
|
+
if (!this.capabilities.includes(sunspec_interfaces_js_1.SunspecInverterCapability.FeedInLimit)) {
|
|
358
|
+
this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.NotSupported, 'FeedInLimit capability not enabled');
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if (!this.isConnected() || !this.applianceId) {
|
|
362
|
+
this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Not connected');
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
console.log(`Inverter ${this.applianceId}: handling SetInverterFeedInLimitV1 (feedInLimitW=${msg.data.feedInLimitW})`);
|
|
366
|
+
const success = await this.sunspecClient.setFeedInLimit(msg.data.feedInLimitW);
|
|
367
|
+
if (!success) {
|
|
368
|
+
this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to set feed-in limit');
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Accepted);
|
|
372
|
+
}
|
|
246
373
|
}
|
|
247
374
|
exports.SunspecInverter = SunspecInverter;
|
|
248
375
|
/**
|
|
249
376
|
* Sunspec Battery implementation
|
|
250
377
|
*/
|
|
251
378
|
class SunspecBattery extends BaseSunspecDevice {
|
|
252
|
-
|
|
253
|
-
|
|
379
|
+
capabilities;
|
|
380
|
+
constructor(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId = 1, port = 502, baseAddress = 40000, capabilities = [], retryConfig) {
|
|
381
|
+
super(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId, port, baseAddress, retryConfig);
|
|
382
|
+
this.capabilities = capabilities;
|
|
383
|
+
}
|
|
254
384
|
/**
|
|
255
385
|
* Connect to the battery and create/update the appliance
|
|
256
386
|
*/
|
|
@@ -337,7 +467,9 @@ class SunspecBattery extends BaseSunspecDevice {
|
|
|
337
467
|
*/
|
|
338
468
|
async readData(clockId, resolution) {
|
|
339
469
|
if (!this.isConnected()) {
|
|
340
|
-
|
|
470
|
+
await this.tryReconnect();
|
|
471
|
+
if (!this.isConnected())
|
|
472
|
+
return [];
|
|
341
473
|
}
|
|
342
474
|
const messages = [];
|
|
343
475
|
const timestamp = new Date();
|
|
@@ -395,6 +527,7 @@ class SunspecBattery extends BaseSunspecDevice {
|
|
|
395
527
|
}
|
|
396
528
|
catch (error) {
|
|
397
529
|
console.error(`Error updating battery data: ${error}`);
|
|
530
|
+
await this.markOffline();
|
|
398
531
|
}
|
|
399
532
|
return messages;
|
|
400
533
|
}
|
|
@@ -711,45 +844,36 @@ class SunspecBattery extends BaseSunspecDevice {
|
|
|
711
844
|
this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.NotSupported);
|
|
712
845
|
return;
|
|
713
846
|
}
|
|
714
|
-
console.log(`Battery ${this.applianceId}: handling SetStorageDischargeLimitV1 (
|
|
715
|
-
// Read current state for
|
|
847
|
+
console.log(`Battery ${this.applianceId}: handling SetStorageDischargeLimitV1 (dischargeLimitW=${msg.data.dischargeLimitW})`);
|
|
848
|
+
// Read current state to get wChaMax for percentage conversion
|
|
716
849
|
const controls = await this.getBatteryControls();
|
|
717
|
-
console.log(`Battery ${this.applianceId}: current state - outWRte=${controls?.outWRte}`);
|
|
850
|
+
console.log(`Battery ${this.applianceId}: current state - outWRte=${controls?.outWRte}, wChaMax=${controls?.wChaMax}`);
|
|
851
|
+
if (!controls?.wChaMax) {
|
|
852
|
+
this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to read wChaMax for discharge limit conversion');
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
// Convert watts to percentage of WDisChaMax (using wChaMax), clamped to 0-100%
|
|
856
|
+
const dischargeLimitPercent = Math.min(100, Math.max(0, (msg.data.dischargeLimitW / controls.wChaMax) * 100));
|
|
857
|
+
console.log(`Battery ${this.applianceId}: calculated discharge limit: ${dischargeLimitPercent.toFixed(1)}% (${msg.data.dischargeLimitW}W / ${controls.wChaMax}W)`);
|
|
718
858
|
// Set discharge limit (Register 12: outWRte)
|
|
719
|
-
const success = await this.writeBatteryControls({ outWRte:
|
|
859
|
+
const success = await this.writeBatteryControls({ outWRte: dischargeLimitPercent });
|
|
720
860
|
if (!success) {
|
|
721
861
|
this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to set discharge limit');
|
|
722
862
|
return;
|
|
723
863
|
}
|
|
724
864
|
this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Accepted);
|
|
725
865
|
}
|
|
726
|
-
sendCommandAcknowledge(messageId, acknowledgeMessage, answer, rejectionReason) {
|
|
727
|
-
if (!this.dataBus || !this.applianceId) {
|
|
728
|
-
return;
|
|
729
|
-
}
|
|
730
|
-
const ackMessage = {
|
|
731
|
-
id: (0, node_crypto_1.randomUUID)(),
|
|
732
|
-
message: enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.CommandAcknowledgeV1,
|
|
733
|
-
type: 'answer',
|
|
734
|
-
source: enyo_source_enum_js_1.EnyoSourceEnum.Device,
|
|
735
|
-
applianceId: this.applianceId,
|
|
736
|
-
timestampIso: new Date().toISOString(),
|
|
737
|
-
data: {
|
|
738
|
-
messageId,
|
|
739
|
-
acknowledgeMessage,
|
|
740
|
-
answer,
|
|
741
|
-
rejectionReason
|
|
742
|
-
}
|
|
743
|
-
};
|
|
744
|
-
console.log(`Battery ${this.applianceId}: sending ${answer} for ${acknowledgeMessage} (messageId=${messageId}${rejectionReason ? `, reason=${rejectionReason}` : ''})`);
|
|
745
|
-
this.dataBus.sendMessage([ackMessage]);
|
|
746
|
-
}
|
|
747
866
|
}
|
|
748
867
|
exports.SunspecBattery = SunspecBattery;
|
|
749
868
|
/**
|
|
750
869
|
* Sunspec Meter implementation
|
|
751
870
|
*/
|
|
752
871
|
class SunspecMeter extends BaseSunspecDevice {
|
|
872
|
+
capabilities;
|
|
873
|
+
constructor(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId = 1, port = 502, baseAddress = 40000, capabilities = [], retryConfig) {
|
|
874
|
+
super(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId, port, baseAddress, retryConfig);
|
|
875
|
+
this.capabilities = capabilities;
|
|
876
|
+
}
|
|
753
877
|
/**
|
|
754
878
|
* Connect to the meter and create/update the appliance
|
|
755
879
|
*/
|
|
@@ -806,7 +930,9 @@ class SunspecMeter extends BaseSunspecDevice {
|
|
|
806
930
|
*/
|
|
807
931
|
async readData(clockId, resolution) {
|
|
808
932
|
if (!this.isConnected()) {
|
|
809
|
-
|
|
933
|
+
await this.tryReconnect();
|
|
934
|
+
if (!this.isConnected())
|
|
935
|
+
return [];
|
|
810
936
|
}
|
|
811
937
|
const messages = [];
|
|
812
938
|
const timestamp = new Date();
|
|
@@ -837,6 +963,7 @@ class SunspecMeter extends BaseSunspecDevice {
|
|
|
837
963
|
}
|
|
838
964
|
catch (error) {
|
|
839
965
|
console.error(`Error updating meter data: ${error}`);
|
|
966
|
+
await this.markOffline();
|
|
840
967
|
}
|
|
841
968
|
return messages;
|
|
842
969
|
}
|