@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.
@@ -1,122 +1,105 @@
1
1
  "use strict";
2
2
  /**
3
- * Connection Retry Manager with Exponential Backoff
3
+ * Connection Retry Manager with Tiered Schedule
4
4
  *
5
- * Manages automatic connection retries with configurable exponential backoff.
6
- * Backoff sequence (with defaults): 1s 1.5s 2.25s 3.375s → 5.06s → 7.59s → 11.39s → 17.09s → 25.63s → 30s (capped)
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
- currentAttempt = 0;
14
- pendingRetryTimeout = null;
15
- isRetrying = false;
20
+ disconnectedSince = null;
21
+ lastAttemptTime = 0;
22
+ attemptCount = 0;
16
23
  constructor(config) {
17
- this.config = { ...sunspec_interfaces_js_1.DEFAULT_RETRY_CONFIG, ...config };
24
+ this.config = config ?? sunspec_interfaces_js_1.DEFAULT_RETRY_CONFIG;
18
25
  }
19
26
  /**
20
- * Calculate the next delay using exponential backoff
21
- * Formula: min(initialDelayMs * backoffFactor^attempt, maxDelayMs)
27
+ * Mark the connection as disconnected. Sets the timestamp if not already set.
22
28
  */
23
- getNextDelay() {
24
- const delay = this.config.initialDelayMs * Math.pow(this.config.backoffFactor, this.currentAttempt);
25
- return Math.min(delay, this.config.maxDelayMs);
29
+ markDisconnected() {
30
+ if (this.disconnectedSince === null) {
31
+ this.disconnectedSince = Date.now();
32
+ }
26
33
  }
27
34
  /**
28
- * Reset the retry state on successful connection
35
+ * Reset all retry state (call on successful reconnect).
29
36
  */
30
37
  reset() {
31
- this.currentAttempt = 0;
32
- this.isRetrying = false;
33
- this.cancelPendingRetry();
38
+ this.disconnectedSince = null;
39
+ this.lastAttemptTime = 0;
40
+ this.attemptCount = 0;
34
41
  }
35
42
  /**
36
- * Check if we should retry based on max attempts
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
- shouldRetry() {
40
- if (this.config.maxAttempts === -1) {
41
- return true;
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 this.currentAttempt < this.config.maxAttempts;
58
+ // Fallback: return last phase
59
+ return this.config.phases[this.config.phases.length - 1];
44
60
  }
45
61
  /**
46
- * Get the current attempt number (0-indexed)
62
+ * Check if we should attempt a reconnection now.
63
+ * Called on every readData() poll cycle.
47
64
  */
48
- getCurrentAttempt() {
49
- return this.currentAttempt;
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
- * Get the max attempts configuration
74
+ * Record that an attempt was just made.
53
75
  */
54
- getMaxAttempts() {
55
- return this.config.maxAttempts;
76
+ recordAttempt() {
77
+ this.lastAttemptTime = Date.now();
78
+ this.attemptCount++;
56
79
  }
57
80
  /**
58
- * Check if a retry is currently in progress
81
+ * Get the number of reconnection attempts made.
59
82
  */
60
- isRetryInProgress() {
61
- return this.isRetrying;
83
+ getAttemptCount() {
84
+ return this.attemptCount;
62
85
  }
63
86
  /**
64
- * Schedule a reconnection attempt with exponential backoff delay
65
- * Returns a promise that resolves when the callback completes
87
+ * Get milliseconds elapsed since disconnect was first detected.
66
88
  */
67
- async scheduleRetry(callback) {
68
- if (!this.shouldRetry()) {
69
- console.log(`Max retry attempts (${this.config.maxAttempts}) reached. Giving up.`);
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
- this.isRetrying = true;
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
- * Cancel any pending retry attempt
96
+ * Check if the manager is currently tracking a disconnection.
109
97
  */
110
- cancelPendingRetry() {
111
- if (this.pendingRetryTimeout) {
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 Exponential Backoff
2
+ * Connection Retry Manager with Tiered Schedule
3
3
  *
4
- * Manages automatic connection retries with configurable exponential backoff.
5
- * Backoff sequence (with defaults): 1s 1.5s 2.25s 3.375s → 5.06s → 7.59s → 11.39s → 17.09s → 25.63s → 30s (capped)
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 currentAttempt;
11
- private pendingRetryTimeout;
12
- private isRetrying;
13
- constructor(config?: Partial<IRetryConfig>);
17
+ private disconnectedSince;
18
+ private lastAttemptTime;
19
+ private attemptCount;
20
+ constructor(config?: IRetryConfig);
14
21
  /**
15
- * Calculate the next delay using exponential backoff
16
- * Formula: min(initialDelayMs * backoffFactor^attempt, maxDelayMs)
22
+ * Mark the connection as disconnected. Sets the timestamp if not already set.
17
23
  */
18
- getNextDelay(): number;
24
+ markDisconnected(): void;
19
25
  /**
20
- * Reset the retry state on successful connection
26
+ * Reset all retry state (call on successful reconnect).
21
27
  */
22
28
  reset(): void;
23
29
  /**
24
- * Check if we should retry based on max attempts
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
- shouldRetry(): boolean;
32
+ getCurrentPhase(): IRetryPhase;
28
33
  /**
29
- * Get the current attempt number (0-indexed)
34
+ * Check if we should attempt a reconnection now.
35
+ * Called on every readData() poll cycle.
30
36
  */
31
- getCurrentAttempt(): number;
37
+ shouldAttemptNow(): boolean;
32
38
  /**
33
- * Get the max attempts configuration
39
+ * Record that an attempt was just made.
34
40
  */
35
- getMaxAttempts(): number;
41
+ recordAttempt(): void;
36
42
  /**
37
- * Check if a retry is currently in progress
43
+ * Get the number of reconnection attempts made.
38
44
  */
39
- isRetryInProgress(): boolean;
45
+ getAttemptCount(): number;
40
46
  /**
41
- * Schedule a reconnection attempt with exponential backoff delay
42
- * Returns a promise that resolves when the callback completes
47
+ * Get milliseconds elapsed since disconnect was first detected.
43
48
  */
44
- scheduleRetry(callback: () => Promise<boolean>): Promise<boolean>;
49
+ getElapsedMs(): number;
45
50
  /**
46
- * Cancel any pending retry attempt
51
+ * Check if the manager is currently tracking a disconnection.
47
52
  */
48
- cancelPendingRetry(): void;
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
- constructor(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId = 1, port = 502, baseAddress = 40000) {
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
- return [];
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
- dataBusListenerId;
253
- dataBus;
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
- return [];
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: advancedBatteryModel ? undefined : mpptBatteryPowerW,
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 (dischargeLimitPercent=${msg.data.dischargeLimitPercent})`);
690
- // Read current state for logging
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: msg.data.dischargeLimitPercent });
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
- return [];
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
  }