@enyo-energy/sunspec-sdk 0.0.31 → 0.0.33
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 +201 -33
- package/dist/cjs/sunspec-devices.d.cts +56 -6
- package/dist/cjs/sunspec-interfaces.cjs +109 -5
- package/dist/cjs/sunspec-interfaces.d.cts +191 -5
- package/dist/cjs/sunspec-modbus-client.cjs +599 -290
- package/dist/cjs/sunspec-modbus-client.d.cts +43 -27
- 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 +56 -6
- package/dist/sunspec-devices.js +201 -33
- package/dist/sunspec-interfaces.d.ts +191 -5
- package/dist/sunspec-interfaces.js +108 -4
- package/dist/sunspec-modbus-client.d.ts +43 -27
- package/dist/sunspec-modbus-client.js +600 -291
- 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,13 +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");
|
|
12
|
+
// TODO: Remove once added to @enyo-energy/energy-app-sdk EnyoDataBusMessageEnum
|
|
13
|
+
exports.ENYO_DATA_BUS_SET_INVERTER_FEED_IN_LIMIT_V1 = 'SetInverterFeedInLimitV1';
|
|
11
14
|
/**
|
|
12
15
|
* Extract battery discharge power from MPPT data.
|
|
13
16
|
* Returns the discharge power in Watts (positive value), or 0 if no discharge.
|
|
@@ -35,7 +38,10 @@ class BaseSunspecDevice {
|
|
|
35
38
|
baseAddress;
|
|
36
39
|
applianceId;
|
|
37
40
|
lastUpdateTime = 0;
|
|
38
|
-
|
|
41
|
+
dataBusListenerId;
|
|
42
|
+
dataBus;
|
|
43
|
+
retryManager;
|
|
44
|
+
constructor(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId = 1, port = 502, baseAddress = 40000, retryConfig) {
|
|
39
45
|
this.energyApp = energyApp;
|
|
40
46
|
this.name = name;
|
|
41
47
|
this.networkDevice = networkDevice;
|
|
@@ -44,6 +50,7 @@ class BaseSunspecDevice {
|
|
|
44
50
|
this.unitId = unitId;
|
|
45
51
|
this.port = port;
|
|
46
52
|
this.baseAddress = baseAddress;
|
|
53
|
+
this.retryManager = new connection_retry_manager_js_1.ConnectionRetryManager(retryConfig);
|
|
47
54
|
}
|
|
48
55
|
/**
|
|
49
56
|
* Check if the device is connected
|
|
@@ -65,12 +72,87 @@ class BaseSunspecDevice {
|
|
|
65
72
|
await this.sunspecClient.discoverModels(this.baseAddress);
|
|
66
73
|
}
|
|
67
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Attempt a reconnection if the tiered retry schedule allows it.
|
|
77
|
+
* Called from readData() when the device is disconnected.
|
|
78
|
+
* Returns true if reconnection succeeded.
|
|
79
|
+
*/
|
|
80
|
+
async tryReconnect() {
|
|
81
|
+
this.retryManager.markDisconnected();
|
|
82
|
+
if (!this.retryManager.shouldAttemptNow()) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
this.retryManager.recordAttempt();
|
|
86
|
+
const phase = this.retryManager.getCurrentPhase();
|
|
87
|
+
const attempt = this.retryManager.getAttemptCount();
|
|
88
|
+
const elapsed = Math.round(this.retryManager.getElapsedMs() / 1000);
|
|
89
|
+
console.log(`${this.constructor.name} ${this.applianceId}: Reconnect attempt #${attempt} ` +
|
|
90
|
+
`(phase: ${phase.intervalMs / 1000}s interval, elapsed: ${elapsed}s)`);
|
|
91
|
+
try {
|
|
92
|
+
const success = await this.sunspecClient.reconnect();
|
|
93
|
+
if (success) {
|
|
94
|
+
// Re-discover models after reconnect
|
|
95
|
+
await this.sunspecClient.discoverModels(this.baseAddress);
|
|
96
|
+
this.retryManager.reset();
|
|
97
|
+
// Update appliance state to Connected
|
|
98
|
+
if (this.applianceId) {
|
|
99
|
+
await this.applianceManager.updateApplianceState(this.applianceId, enyo_appliance_js_1.EnyoApplianceConnectionType.Connector, enyo_appliance_js_1.EnyoApplianceStateEnum.Connected);
|
|
100
|
+
}
|
|
101
|
+
console.log(`${this.constructor.name} ${this.applianceId}: Reconnection successful after ${attempt} attempt(s)`);
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
console.error(`${this.constructor.name} ${this.applianceId}: Reconnect attempt #${attempt} failed: ${error}`);
|
|
107
|
+
}
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Mark the device as offline: update appliance state and start tracking disconnection.
|
|
112
|
+
*/
|
|
113
|
+
async markOffline() {
|
|
114
|
+
this.retryManager.markDisconnected();
|
|
115
|
+
if (this.applianceId) {
|
|
116
|
+
try {
|
|
117
|
+
await this.applianceManager.updateApplianceState(this.applianceId, enyo_appliance_js_1.EnyoApplianceConnectionType.Connector, enyo_appliance_js_1.EnyoApplianceStateEnum.Offline);
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
console.error(`${this.constructor.name} ${this.applianceId}: Failed to mark appliance offline: ${error}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
sendCommandAcknowledge(messageId, acknowledgeMessage, answer, rejectionReason) {
|
|
125
|
+
if (!this.dataBus || !this.applianceId) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const ackMessage = {
|
|
129
|
+
id: (0, node_crypto_1.randomUUID)(),
|
|
130
|
+
message: enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.CommandAcknowledgeV1,
|
|
131
|
+
type: 'answer',
|
|
132
|
+
source: enyo_source_enum_js_1.EnyoSourceEnum.Device,
|
|
133
|
+
applianceId: this.applianceId,
|
|
134
|
+
timestampIso: new Date().toISOString(),
|
|
135
|
+
data: {
|
|
136
|
+
messageId,
|
|
137
|
+
acknowledgeMessage: acknowledgeMessage,
|
|
138
|
+
answer,
|
|
139
|
+
rejectionReason
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
console.log(`${this.constructor.name} ${this.applianceId}: sending ${answer} for ${acknowledgeMessage} (messageId=${messageId}${rejectionReason ? `, reason=${rejectionReason}` : ''})`);
|
|
143
|
+
this.dataBus.sendMessage([ackMessage]);
|
|
144
|
+
}
|
|
68
145
|
}
|
|
69
146
|
exports.BaseSunspecDevice = BaseSunspecDevice;
|
|
70
147
|
/**
|
|
71
148
|
* Sunspec Inverter implementation using dynamic model discovery
|
|
72
149
|
*/
|
|
73
150
|
class SunspecInverter extends BaseSunspecDevice {
|
|
151
|
+
capabilities;
|
|
152
|
+
constructor(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId = 1, port = 502, baseAddress = 40000, capabilities = [], retryConfig) {
|
|
153
|
+
super(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId, port, baseAddress, retryConfig);
|
|
154
|
+
this.capabilities = capabilities;
|
|
155
|
+
}
|
|
74
156
|
async connect() {
|
|
75
157
|
// Ensure Sunspec client is connected
|
|
76
158
|
if (!this.sunspecClient.isConnected()) {
|
|
@@ -111,8 +193,10 @@ class SunspecInverter extends BaseSunspecDevice {
|
|
|
111
193
|
if (mpptModel) {
|
|
112
194
|
console.log(`MPPT model found for inverter ${this.networkDevice.hostname}`);
|
|
113
195
|
}
|
|
196
|
+
this.startDataBusListening();
|
|
114
197
|
}
|
|
115
198
|
async disconnect() {
|
|
199
|
+
this.stopDataBusListening();
|
|
116
200
|
if (this.applianceId) {
|
|
117
201
|
await this.applianceManager.updateApplianceState(this.applianceId, enyo_appliance_js_1.EnyoApplianceConnectionType.Connector, enyo_appliance_js_1.EnyoApplianceStateEnum.Offline);
|
|
118
202
|
}
|
|
@@ -123,7 +207,9 @@ class SunspecInverter extends BaseSunspecDevice {
|
|
|
123
207
|
}
|
|
124
208
|
async readData(clockId, resolution) {
|
|
125
209
|
if (!this.isConnected()) {
|
|
126
|
-
|
|
210
|
+
await this.tryReconnect();
|
|
211
|
+
if (!this.isConnected())
|
|
212
|
+
return [];
|
|
127
213
|
}
|
|
128
214
|
const messages = [];
|
|
129
215
|
const timestamp = new Date();
|
|
@@ -173,6 +259,7 @@ class SunspecInverter extends BaseSunspecDevice {
|
|
|
173
259
|
}
|
|
174
260
|
catch (error) {
|
|
175
261
|
console.error(`Error updating inverter data: ${error}`);
|
|
262
|
+
await this.markOffline();
|
|
176
263
|
}
|
|
177
264
|
return messages;
|
|
178
265
|
}
|
|
@@ -243,14 +330,73 @@ class SunspecInverter extends BaseSunspecDevice {
|
|
|
243
330
|
mapDcStringToApplianceMetadata(mpptDataList) {
|
|
244
331
|
return mpptDataList.map(s => ({ index: s.index, name: s.name }));
|
|
245
332
|
}
|
|
333
|
+
/**
|
|
334
|
+
* Start listening for inverter commands on the data bus.
|
|
335
|
+
* Idempotent — does nothing if already listening.
|
|
336
|
+
*/
|
|
337
|
+
startDataBusListening() {
|
|
338
|
+
if (this.dataBusListenerId) {
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
this.dataBus = this.energyApp.useDataBus();
|
|
342
|
+
this.dataBusListenerId = this.dataBus.listenForMessages([exports.ENYO_DATA_BUS_SET_INVERTER_FEED_IN_LIMIT_V1], (entry) => this.handleInverterCommand(entry));
|
|
343
|
+
console.log(`Inverter ${this.applianceId}: started data bus listening (listener ${this.dataBusListenerId})`);
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Stop listening for inverter commands on the data bus.
|
|
347
|
+
*/
|
|
348
|
+
stopDataBusListening() {
|
|
349
|
+
if (this.dataBusListenerId && this.dataBus) {
|
|
350
|
+
this.dataBus.unsubscribe(this.dataBusListenerId);
|
|
351
|
+
console.log(`Inverter ${this.applianceId}: stopped data bus listening (listener ${this.dataBusListenerId})`);
|
|
352
|
+
}
|
|
353
|
+
this.dataBusListenerId = undefined;
|
|
354
|
+
this.dataBus = undefined;
|
|
355
|
+
}
|
|
356
|
+
handleInverterCommand(entry) {
|
|
357
|
+
if (entry.applianceId !== this.applianceId) {
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
void (async () => {
|
|
361
|
+
try {
|
|
362
|
+
if (entry.message === exports.ENYO_DATA_BUS_SET_INVERTER_FEED_IN_LIMIT_V1) {
|
|
363
|
+
await this.handleSetFeedInLimit(entry);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
catch (error) {
|
|
367
|
+
console.error(`Inverter ${this.applianceId}: error handling ${entry.message}:`, error);
|
|
368
|
+
}
|
|
369
|
+
})();
|
|
370
|
+
}
|
|
371
|
+
async handleSetFeedInLimit(msg) {
|
|
372
|
+
// Check capability
|
|
373
|
+
if (!this.capabilities.includes(sunspec_interfaces_js_1.SunspecInverterCapability.FeedInLimit)) {
|
|
374
|
+
this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.NotSupported, 'FeedInLimit capability not enabled');
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
if (!this.isConnected() || !this.applianceId) {
|
|
378
|
+
this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Not connected');
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
console.log(`Inverter ${this.applianceId}: handling SetInverterFeedInLimitV1 (feedInLimitW=${msg.data.feedInLimitW})`);
|
|
382
|
+
const success = await this.sunspecClient.setFeedInLimit(msg.data.feedInLimitW);
|
|
383
|
+
if (!success) {
|
|
384
|
+
this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to set feed-in limit');
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Accepted);
|
|
388
|
+
}
|
|
246
389
|
}
|
|
247
390
|
exports.SunspecInverter = SunspecInverter;
|
|
248
391
|
/**
|
|
249
392
|
* Sunspec Battery implementation
|
|
250
393
|
*/
|
|
251
394
|
class SunspecBattery extends BaseSunspecDevice {
|
|
252
|
-
|
|
253
|
-
|
|
395
|
+
capabilities;
|
|
396
|
+
constructor(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId = 1, port = 502, baseAddress = 40000, capabilities = [], retryConfig) {
|
|
397
|
+
super(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId, port, baseAddress, retryConfig);
|
|
398
|
+
this.capabilities = capabilities;
|
|
399
|
+
}
|
|
254
400
|
/**
|
|
255
401
|
* Connect to the battery and create/update the appliance
|
|
256
402
|
*/
|
|
@@ -337,7 +483,9 @@ class SunspecBattery extends BaseSunspecDevice {
|
|
|
337
483
|
*/
|
|
338
484
|
async readData(clockId, resolution) {
|
|
339
485
|
if (!this.isConnected()) {
|
|
340
|
-
|
|
486
|
+
await this.tryReconnect();
|
|
487
|
+
if (!this.isConnected())
|
|
488
|
+
return [];
|
|
341
489
|
}
|
|
342
490
|
const messages = [];
|
|
343
491
|
const timestamp = new Date();
|
|
@@ -348,6 +496,16 @@ class SunspecBattery extends BaseSunspecDevice {
|
|
|
348
496
|
const mpptBatteryPowerW = this.extractBatteryPowerFromMPPT(mpptDataList);
|
|
349
497
|
if (batteryData) {
|
|
350
498
|
const advancedBatteryModel = this.sunspecClient.findModel(801);
|
|
499
|
+
const batteryBaseModel = this.sunspecClient.findModel(sunspec_interfaces_js_1.SunspecModelId.BatteryBase);
|
|
500
|
+
// Determine battery power: prefer model 802 w field, then MPPT extraction, then undefined
|
|
501
|
+
let batteryPowerW;
|
|
502
|
+
if (batteryBaseModel && (batteryData.chargePower !== undefined || batteryData.dischargePower !== undefined)) {
|
|
503
|
+
// Model 802 provides power directly: positive = charge, negative = discharge
|
|
504
|
+
batteryPowerW = (batteryData.chargePower || 0) - (batteryData.dischargePower || 0);
|
|
505
|
+
}
|
|
506
|
+
else if (!advancedBatteryModel) {
|
|
507
|
+
batteryPowerW = mpptBatteryPowerW;
|
|
508
|
+
}
|
|
351
509
|
const batteryMessage = {
|
|
352
510
|
id: (0, node_crypto_1.randomUUID)(),
|
|
353
511
|
message: enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.BatteryValuesUpdateV1,
|
|
@@ -359,7 +517,7 @@ class SunspecBattery extends BaseSunspecDevice {
|
|
|
359
517
|
resolution,
|
|
360
518
|
data: {
|
|
361
519
|
batterySoC: batteryData.soc || batteryData.chaState || 0,
|
|
362
|
-
batteryPowerW
|
|
520
|
+
batteryPowerW,
|
|
363
521
|
state: this.mapToEnyoBatteryState(batteryData.chaSt),
|
|
364
522
|
}
|
|
365
523
|
};
|
|
@@ -385,6 +543,7 @@ class SunspecBattery extends BaseSunspecDevice {
|
|
|
385
543
|
}
|
|
386
544
|
catch (error) {
|
|
387
545
|
console.error(`Error updating battery data: ${error}`);
|
|
546
|
+
await this.markOffline();
|
|
388
547
|
}
|
|
389
548
|
return messages;
|
|
390
549
|
}
|
|
@@ -499,6 +658,21 @@ class SunspecBattery extends BaseSunspecDevice {
|
|
|
499
658
|
}
|
|
500
659
|
return this.sunspecClient.readBatteryControls();
|
|
501
660
|
}
|
|
661
|
+
/**
|
|
662
|
+
* Read full battery base data from Model 802
|
|
663
|
+
*
|
|
664
|
+
* Returns the complete Model 802 data structure with all fields,
|
|
665
|
+
* including nameplate, SoC/health, status, events, voltage, current, and power.
|
|
666
|
+
*
|
|
667
|
+
* @returns Promise<SunspecBatteryBaseData | null> - Full model 802 data or null if not available
|
|
668
|
+
*/
|
|
669
|
+
async readBatteryBaseData() {
|
|
670
|
+
if (!this.isConnected()) {
|
|
671
|
+
console.error('Battery not connected');
|
|
672
|
+
return null;
|
|
673
|
+
}
|
|
674
|
+
return this.sunspecClient.readBatteryBaseData();
|
|
675
|
+
}
|
|
502
676
|
/**
|
|
503
677
|
* Write custom battery control settings
|
|
504
678
|
*
|
|
@@ -686,45 +860,36 @@ class SunspecBattery extends BaseSunspecDevice {
|
|
|
686
860
|
this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.NotSupported);
|
|
687
861
|
return;
|
|
688
862
|
}
|
|
689
|
-
console.log(`Battery ${this.applianceId}: handling SetStorageDischargeLimitV1 (
|
|
690
|
-
// Read current state for
|
|
863
|
+
console.log(`Battery ${this.applianceId}: handling SetStorageDischargeLimitV1 (dischargeLimitW=${msg.data.dischargeLimitW})`);
|
|
864
|
+
// Read current state to get wChaMax for percentage conversion
|
|
691
865
|
const controls = await this.getBatteryControls();
|
|
692
|
-
console.log(`Battery ${this.applianceId}: current state - outWRte=${controls?.outWRte}`);
|
|
866
|
+
console.log(`Battery ${this.applianceId}: current state - outWRte=${controls?.outWRte}, wChaMax=${controls?.wChaMax}`);
|
|
867
|
+
if (!controls?.wChaMax) {
|
|
868
|
+
this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to read wChaMax for discharge limit conversion');
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
// Convert watts to percentage of WDisChaMax (using wChaMax), clamped to 0-100%
|
|
872
|
+
const dischargeLimitPercent = Math.min(100, Math.max(0, (msg.data.dischargeLimitW / controls.wChaMax) * 100));
|
|
873
|
+
console.log(`Battery ${this.applianceId}: calculated discharge limit: ${dischargeLimitPercent.toFixed(1)}% (${msg.data.dischargeLimitW}W / ${controls.wChaMax}W)`);
|
|
693
874
|
// Set discharge limit (Register 12: outWRte)
|
|
694
|
-
const success = await this.writeBatteryControls({ outWRte:
|
|
875
|
+
const success = await this.writeBatteryControls({ outWRte: dischargeLimitPercent });
|
|
695
876
|
if (!success) {
|
|
696
877
|
this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to set discharge limit');
|
|
697
878
|
return;
|
|
698
879
|
}
|
|
699
880
|
this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Accepted);
|
|
700
881
|
}
|
|
701
|
-
sendCommandAcknowledge(messageId, acknowledgeMessage, answer, rejectionReason) {
|
|
702
|
-
if (!this.dataBus || !this.applianceId) {
|
|
703
|
-
return;
|
|
704
|
-
}
|
|
705
|
-
const ackMessage = {
|
|
706
|
-
id: (0, node_crypto_1.randomUUID)(),
|
|
707
|
-
message: enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.CommandAcknowledgeV1,
|
|
708
|
-
type: 'answer',
|
|
709
|
-
source: enyo_source_enum_js_1.EnyoSourceEnum.Device,
|
|
710
|
-
applianceId: this.applianceId,
|
|
711
|
-
timestampIso: new Date().toISOString(),
|
|
712
|
-
data: {
|
|
713
|
-
messageId,
|
|
714
|
-
acknowledgeMessage,
|
|
715
|
-
answer,
|
|
716
|
-
rejectionReason
|
|
717
|
-
}
|
|
718
|
-
};
|
|
719
|
-
console.log(`Battery ${this.applianceId}: sending ${answer} for ${acknowledgeMessage} (messageId=${messageId}${rejectionReason ? `, reason=${rejectionReason}` : ''})`);
|
|
720
|
-
this.dataBus.sendMessage([ackMessage]);
|
|
721
|
-
}
|
|
722
882
|
}
|
|
723
883
|
exports.SunspecBattery = SunspecBattery;
|
|
724
884
|
/**
|
|
725
885
|
* Sunspec Meter implementation
|
|
726
886
|
*/
|
|
727
887
|
class SunspecMeter extends BaseSunspecDevice {
|
|
888
|
+
capabilities;
|
|
889
|
+
constructor(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId = 1, port = 502, baseAddress = 40000, capabilities = [], retryConfig) {
|
|
890
|
+
super(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId, port, baseAddress, retryConfig);
|
|
891
|
+
this.capabilities = capabilities;
|
|
892
|
+
}
|
|
728
893
|
/**
|
|
729
894
|
* Connect to the meter and create/update the appliance
|
|
730
895
|
*/
|
|
@@ -781,7 +946,9 @@ class SunspecMeter extends BaseSunspecDevice {
|
|
|
781
946
|
*/
|
|
782
947
|
async readData(clockId, resolution) {
|
|
783
948
|
if (!this.isConnected()) {
|
|
784
|
-
|
|
949
|
+
await this.tryReconnect();
|
|
950
|
+
if (!this.isConnected())
|
|
951
|
+
return [];
|
|
785
952
|
}
|
|
786
953
|
const messages = [];
|
|
787
954
|
const timestamp = new Date();
|
|
@@ -812,6 +979,7 @@ class SunspecMeter extends BaseSunspecDevice {
|
|
|
812
979
|
}
|
|
813
980
|
catch (error) {
|
|
814
981
|
console.error(`Error updating meter data: ${error}`);
|
|
982
|
+
await this.markOffline();
|
|
815
983
|
}
|
|
816
984
|
return messages;
|
|
817
985
|
}
|