@enyo-energy/sunspec-sdk 0.0.29 → 0.0.31

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.
@@ -31,15 +31,19 @@ class BaseSunspecDevice {
31
31
  sunspecClient;
32
32
  applianceManager;
33
33
  unitId;
34
+ port;
35
+ baseAddress;
34
36
  applianceId;
35
37
  lastUpdateTime = 0;
36
- constructor(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId = 1) {
38
+ constructor(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId = 1, port = 502, baseAddress = 40000) {
37
39
  this.energyApp = energyApp;
38
40
  this.name = name;
39
41
  this.networkDevice = networkDevice;
40
42
  this.sunspecClient = sunspecClient;
41
43
  this.applianceManager = applianceManager;
42
44
  this.unitId = unitId;
45
+ this.port = port;
46
+ this.baseAddress = baseAddress;
43
47
  }
44
48
  /**
45
49
  * Check if the device is connected
@@ -56,9 +60,9 @@ class BaseSunspecDevice {
56
60
  async ensureConnected() {
57
61
  if (!this.sunspecClient.isConnected()) {
58
62
  await this.sunspecClient.connect(this.networkDevice.hostname, // primary
59
- 502, this.unitId, this.networkDevice.ipAddress // secondary fallback
63
+ this.port, this.unitId, this.networkDevice.ipAddress // secondary fallback
60
64
  );
61
- await this.sunspecClient.discoverModels();
65
+ await this.sunspecClient.discoverModels(this.baseAddress);
62
66
  }
63
67
  }
64
68
  }
@@ -71,9 +75,9 @@ class SunspecInverter extends BaseSunspecDevice {
71
75
  // Ensure Sunspec client is connected
72
76
  if (!this.sunspecClient.isConnected()) {
73
77
  await this.sunspecClient.connect(this.networkDevice.hostname, // primary
74
- 502, this.unitId, this.networkDevice.ipAddress // secondary fallback
78
+ this.port, this.unitId, this.networkDevice.ipAddress // secondary fallback
75
79
  );
76
- await this.sunspecClient.discoverModels();
80
+ await this.sunspecClient.discoverModels(this.baseAddress);
77
81
  }
78
82
  // Get device info from common block
79
83
  const commonData = await this.sunspecClient.readCommonBlock();
@@ -245,6 +249,8 @@ exports.SunspecInverter = SunspecInverter;
245
249
  * Sunspec Battery implementation
246
250
  */
247
251
  class SunspecBattery extends BaseSunspecDevice {
252
+ dataBusListenerId;
253
+ dataBus;
248
254
  /**
249
255
  * Connect to the battery and create/update the appliance
250
256
  */
@@ -288,12 +294,14 @@ class SunspecBattery extends BaseSunspecDevice {
288
294
  }
289
295
  });
290
296
  console.log(`Sunspec Battery connected: ${this.networkDevice.hostname} (${this.applianceId})`);
297
+ this.startDataBusListening();
291
298
  }
292
299
  catch (error) {
293
300
  console.error(`Failed to create battery appliance: ${error}`);
294
301
  }
295
302
  }
296
303
  async disconnect() {
304
+ this.stopDataBusListening();
297
305
  if (this.applianceId) {
298
306
  await this.applianceManager.updateApplianceState(this.applianceId, enyo_appliance_js_1.EnyoApplianceConnectionType.Connector, enyo_appliance_js_1.EnyoApplianceStateEnum.Offline);
299
307
  }
@@ -562,6 +570,155 @@ class SunspecBattery extends BaseSunspecDevice {
562
570
  }
563
571
  return controls.chaGriSet === 1; // 1 = GRID, 0 = PV
564
572
  }
573
+ /**
574
+ * Start listening for storage commands on the data bus.
575
+ * Idempotent — does nothing if already listening.
576
+ */
577
+ startDataBusListening() {
578
+ if (this.dataBusListenerId) {
579
+ return;
580
+ }
581
+ this.dataBus = this.energyApp.useDataBus();
582
+ this.dataBusListenerId = this.dataBus.listenForMessages([
583
+ enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.StartStorageGridChargeV1,
584
+ enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.StopStorageGridChargeV1,
585
+ enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.SetStorageDischargeLimitV1
586
+ ], (entry) => this.handleStorageCommand(entry));
587
+ console.log(`Battery ${this.applianceId}: started data bus listening (listener ${this.dataBusListenerId})`);
588
+ }
589
+ /**
590
+ * Stop listening for storage commands on the data bus.
591
+ */
592
+ stopDataBusListening() {
593
+ if (this.dataBusListenerId && this.dataBus) {
594
+ this.dataBus.unsubscribe(this.dataBusListenerId);
595
+ console.log(`Battery ${this.applianceId}: stopped data bus listening (listener ${this.dataBusListenerId})`);
596
+ }
597
+ this.dataBusListenerId = undefined;
598
+ this.dataBus = undefined;
599
+ }
600
+ handleStorageCommand(entry) {
601
+ if (entry.applianceId !== this.applianceId) {
602
+ return;
603
+ }
604
+ void (async () => {
605
+ try {
606
+ switch (entry.message) {
607
+ case enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.StartStorageGridChargeV1:
608
+ await this.handleStartGridCharge(entry);
609
+ break;
610
+ case enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.StopStorageGridChargeV1:
611
+ await this.handleStopGridCharge(entry);
612
+ break;
613
+ case enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.SetStorageDischargeLimitV1:
614
+ await this.handleSetDischargeLimit(entry);
615
+ break;
616
+ }
617
+ }
618
+ catch (error) {
619
+ console.error(`Battery ${this.applianceId}: error handling ${entry.message}:`, error);
620
+ }
621
+ })();
622
+ }
623
+ async handleStartGridCharge(msg) {
624
+ if (!this.isConnected() || !this.applianceId) {
625
+ this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, "Not connected");
626
+ return;
627
+ }
628
+ console.log(`Battery ${this.applianceId}: handling StartStorageGridChargeV1 (powerLimitW=${msg.data.powerLimitW})`);
629
+ // Read current state for logging and rollback
630
+ const controls = await this.getBatteryControls();
631
+ console.log(`Battery ${this.applianceId}: current state - chaGriSet=${controls?.chaGriSet}, wChaMax=${controls?.wChaMax}, storCtlMod=${controls?.storCtlMod}`);
632
+ const originalChaGriSet = controls?.chaGriSet;
633
+ const originalWChaMax = controls?.wChaMax;
634
+ // Step 1: Enable grid charging (Register 17: chaGriSet=GRID)
635
+ const step1 = await this.enableGridCharging(true);
636
+ if (!step1) {
637
+ this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to enable grid charging');
638
+ return;
639
+ }
640
+ // Step 2: Set charging power (Register 2: wChaMax)
641
+ const step2 = await this.setChargingPower(msg.data.powerLimitW);
642
+ if (!step2) {
643
+ // Rollback step 1
644
+ await this.writeBatteryControls({ chaGriSet: originalChaGriSet });
645
+ this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to set charging power');
646
+ return;
647
+ }
648
+ // Step 3: Set storage mode to CHARGE (Register 5: storCtlMod=0x0001)
649
+ const step3 = await this.setStorageMode(sunspec_interfaces_js_1.SunspecStorageMode.CHARGE);
650
+ if (!step3) {
651
+ // Rollback steps 1+2
652
+ await this.writeBatteryControls({ chaGriSet: originalChaGriSet, wChaMax: originalWChaMax });
653
+ this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to set storage mode to charge');
654
+ return;
655
+ }
656
+ this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Accepted);
657
+ }
658
+ async handleStopGridCharge(msg) {
659
+ if (!this.isConnected() || !this.applianceId) {
660
+ this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.NotSupported);
661
+ return;
662
+ }
663
+ console.log(`Battery ${this.applianceId}: handling StopStorageGridChargeV1`);
664
+ // Read current state for logging and rollback
665
+ const controls = await this.getBatteryControls();
666
+ console.log(`Battery ${this.applianceId}: current state - chaGriSet=${controls?.chaGriSet}, storCtlMod=${controls?.storCtlMod}`);
667
+ const originalChaGriSet = controls?.chaGriSet;
668
+ // Step 1: Disable grid charging (Register 17: chaGriSet=PV)
669
+ const step1 = await this.enableGridCharging(false);
670
+ if (!step1) {
671
+ this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to disable grid charging');
672
+ return;
673
+ }
674
+ // Step 2: Set storage mode to AUTO (Register 5: storCtlMod=0x0003)
675
+ const step2 = await this.setStorageMode(sunspec_interfaces_js_1.SunspecStorageMode.AUTO);
676
+ if (!step2) {
677
+ // Rollback step 1
678
+ await this.writeBatteryControls({ chaGriSet: originalChaGriSet });
679
+ this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to set storage mode to auto');
680
+ return;
681
+ }
682
+ this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Accepted);
683
+ }
684
+ async handleSetDischargeLimit(msg) {
685
+ if (!this.isConnected() || !this.applianceId) {
686
+ this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.NotSupported);
687
+ return;
688
+ }
689
+ console.log(`Battery ${this.applianceId}: handling SetStorageDischargeLimitV1 (dischargeLimitPercent=${msg.data.dischargeLimitPercent})`);
690
+ // Read current state for logging
691
+ const controls = await this.getBatteryControls();
692
+ console.log(`Battery ${this.applianceId}: current state - outWRte=${controls?.outWRte}`);
693
+ // Set discharge limit (Register 12: outWRte)
694
+ const success = await this.writeBatteryControls({ outWRte: msg.data.dischargeLimitPercent });
695
+ if (!success) {
696
+ this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to set discharge limit');
697
+ return;
698
+ }
699
+ this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Accepted);
700
+ }
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
+ }
565
722
  }
566
723
  exports.SunspecBattery = SunspecBattery;
567
724
  /**
@@ -14,9 +14,11 @@ export declare abstract class BaseSunspecDevice {
14
14
  protected readonly sunspecClient: SunspecModbusClient;
15
15
  protected readonly applianceManager: ApplianceManager;
16
16
  protected readonly unitId: number;
17
+ protected readonly port: number;
18
+ protected readonly baseAddress: number;
17
19
  protected applianceId?: string;
18
20
  protected lastUpdateTime: number;
19
- constructor(energyApp: EnergyApp, name: EnyoApplianceName[], networkDevice: EnyoNetworkDevice, sunspecClient: SunspecModbusClient, applianceManager: ApplianceManager, unitId?: number);
21
+ constructor(energyApp: EnergyApp, name: EnyoApplianceName[], networkDevice: EnyoNetworkDevice, sunspecClient: SunspecModbusClient, applianceManager: ApplianceManager, unitId?: number, port?: number, baseAddress?: number);
20
22
  /**
21
23
  * Connect to the device and create/update the appliance
22
24
  */
@@ -62,6 +64,8 @@ export declare class SunspecInverter extends BaseSunspecDevice {
62
64
  * Sunspec Battery implementation
63
65
  */
64
66
  export declare class SunspecBattery extends BaseSunspecDevice {
67
+ private dataBusListenerId?;
68
+ private dataBus?;
65
69
  /**
66
70
  * Connect to the battery and create/update the appliance
67
71
  */
@@ -150,6 +154,20 @@ export declare class SunspecBattery extends BaseSunspecDevice {
150
154
  * @returns Promise<boolean | null> - true if enabled, false if disabled, null if error
151
155
  */
152
156
  isGridChargingEnabled(): Promise<boolean | null>;
157
+ /**
158
+ * Start listening for storage commands on the data bus.
159
+ * Idempotent — does nothing if already listening.
160
+ */
161
+ startDataBusListening(): void;
162
+ /**
163
+ * Stop listening for storage commands on the data bus.
164
+ */
165
+ stopDataBusListening(): void;
166
+ private handleStorageCommand;
167
+ private handleStartGridCharge;
168
+ private handleStopGridCharge;
169
+ private handleSetDischargeLimit;
170
+ private sendCommandAcknowledge;
153
171
  }
154
172
  /**
155
173
  * Sunspec Meter implementation
@@ -29,7 +29,6 @@ class SunspecModbusClient {
29
29
  modbusClient = null;
30
30
  discoveredModels = new Map();
31
31
  connected = false;
32
- baseAddress = 40001;
33
32
  connectionHealth;
34
33
  faultTolerantReader = null;
35
34
  modbusDataTypeConverter;
@@ -225,49 +224,86 @@ class SunspecModbusClient {
225
224
  /**
226
225
  * Detect the base address and addressing mode (0-based or 1-based) for SunSpec
227
226
  */
228
- async detectSunspecBaseAddress() {
227
+ async detectSunspecBaseAddress(customBaseAddress) {
229
228
  if (!this.modbusClient) {
230
229
  throw new Error('Modbus client not initialized');
231
230
  }
232
- // Try 1-based addressing first (most common)
233
- try {
234
- const sunspecId = await this.modbusClient.readRegisterStringValue(40001, 2);
235
- if (sunspecId.includes('SunS')) {
236
- console.log('Detected 1-based addressing mode (base address: 40001)');
237
- this.baseAddress = 40001;
238
- return {
239
- baseAddress: 40001,
240
- isZeroBased: false,
241
- nextAddress: 40003
242
- };
231
+ // If custom base address provided, try it first (1-based, then 0-based variant)
232
+ if (customBaseAddress !== undefined) {
233
+ console.log(`Detect models for custom base address '${customBaseAddress}' ...`);
234
+ // Try 0-based at custom address
235
+ try {
236
+ const sunspecId = await this.modbusClient.readRegisterStringValue(customBaseAddress, 2);
237
+ if (sunspecId.includes('SunS')) {
238
+ console.log(`Detected 0-based addressing mode (base address: ${customBaseAddress})`);
239
+ return {
240
+ baseAddress: customBaseAddress,
241
+ isZeroBased: true,
242
+ nextAddress: customBaseAddress + 2
243
+ };
244
+ }
243
245
  }
244
- }
245
- catch (error) {
246
- console.debug('Could not read SunS at 40001:', error);
247
- }
248
- // Try 0-based addressing
249
- try {
250
- const sunspecId = await this.modbusClient.readRegisterStringValue(40000, 2);
251
- if (sunspecId.includes('SunS')) {
252
- console.log('Detected 0-based addressing mode (base address: 40000)');
253
- this.baseAddress = 40000;
254
- return {
255
- baseAddress: 40000,
256
- isZeroBased: true,
257
- nextAddress: 40002
258
- };
246
+ catch (error) {
247
+ console.debug(`Could not read SunS at ${customBaseAddress}:`, error);
248
+ }
249
+ // Try 1-based at custom address (customBaseAddress + 1)
250
+ try {
251
+ const sunspecId = await this.modbusClient.readRegisterStringValue(customBaseAddress + 1, 2);
252
+ if (sunspecId.includes('SunS')) {
253
+ console.log(`Detected 1-based addressing mode (base address: ${customBaseAddress + 1})`);
254
+ return {
255
+ baseAddress: customBaseAddress + 1,
256
+ isZeroBased: false,
257
+ nextAddress: customBaseAddress + 3
258
+ };
259
+ }
260
+ }
261
+ catch (error) {
262
+ console.debug(`Could not read SunS at ${customBaseAddress + 1}:`, error);
259
263
  }
260
264
  }
261
- catch (error) {
262
- console.debug('Could not read SunS at 40000:', error);
265
+ else {
266
+ // Try 1-based addressing first (most common)
267
+ try {
268
+ const sunspecId = await this.modbusClient.readRegisterStringValue(40001, 2);
269
+ if (sunspecId.includes('SunS')) {
270
+ console.log('Detected 1-based addressing mode (base address: 40001)');
271
+ return {
272
+ baseAddress: 40001,
273
+ isZeroBased: false,
274
+ nextAddress: 40003
275
+ };
276
+ }
277
+ }
278
+ catch (error) {
279
+ console.debug('Could not read SunS at 40001:', error);
280
+ }
281
+ // Try 0-based addressing
282
+ try {
283
+ const sunspecId = await this.modbusClient.readRegisterStringValue(40000, 2);
284
+ if (sunspecId.includes('SunS')) {
285
+ console.log('Detected 0-based addressing mode (base address: 40000)');
286
+ return {
287
+ baseAddress: 40000,
288
+ isZeroBased: true,
289
+ nextAddress: 40002
290
+ };
291
+ }
292
+ }
293
+ catch (error) {
294
+ console.debug('Could not read SunS at 40000:', error);
295
+ }
263
296
  }
264
- throw new Error('Device is not SunSpec compliant - "SunS" identifier not found at addresses 40000 or 40001');
297
+ const addressesChecked = customBaseAddress !== undefined
298
+ ? `${customBaseAddress}, ${customBaseAddress + 1}, 40000, or 40001`
299
+ : '40000 or 40001';
300
+ throw new Error(`Device is not SunSpec compliant - "SunS" identifier not found at addresses ${addressesChecked}`);
265
301
  }
266
302
  /**
267
303
  * Discover all available Sunspec models
268
304
  * Automatically detects base address (40000 or 40001) and scans from there
269
305
  */
270
- async discoverModels() {
306
+ async discoverModels(customBaseAddress) {
271
307
  if (!this.connected) {
272
308
  throw new Error('Not connected to Modbus device');
273
309
  }
@@ -277,7 +313,7 @@ class SunspecModbusClient {
277
313
  console.log('Starting Sunspec model discovery...');
278
314
  try {
279
315
  // Detect the base address and addressing mode
280
- const addressInfo = await this.detectSunspecBaseAddress();
316
+ const addressInfo = await this.detectSunspecBaseAddress(customBaseAddress);
281
317
  currentAddress = addressInfo.nextAddress;
282
318
  while (currentAddress < maxAddress) {
283
319
  // Read model ID and length
@@ -25,7 +25,6 @@ export declare class SunspecModbusClient {
25
25
  private modbusClient;
26
26
  private discoveredModels;
27
27
  private connected;
28
- private baseAddress;
29
28
  private connectionHealth;
30
29
  private faultTolerantReader;
31
30
  private modbusDataTypeConverter;
@@ -76,7 +75,7 @@ export declare class SunspecModbusClient {
76
75
  /**
77
76
  * Detect the base address and addressing mode (0-based or 1-based) for SunSpec
78
77
  */
79
- detectSunspecBaseAddress(): Promise<{
78
+ detectSunspecBaseAddress(customBaseAddress?: number): Promise<{
80
79
  baseAddress: number;
81
80
  isZeroBased: boolean;
82
81
  nextAddress: number;
@@ -85,7 +84,7 @@ export declare class SunspecModbusClient {
85
84
  * Discover all available Sunspec models
86
85
  * Automatically detects base address (40000 or 40001) and scans from there
87
86
  */
88
- discoverModels(): Promise<Map<number, SunspecModel>>;
87
+ discoverModels(customBaseAddress?: number): Promise<Map<number, SunspecModel>>;
89
88
  /**
90
89
  * Find a specific model by ID
91
90
  */
@@ -9,7 +9,7 @@ exports.getSdkVersion = getSdkVersion;
9
9
  /**
10
10
  * Current version of the enyo Energy App SDK.
11
11
  */
12
- exports.SDK_VERSION = '0.0.29';
12
+ exports.SDK_VERSION = '0.0.31';
13
13
  /**
14
14
  * Gets the current SDK version.
15
15
  * @returns The semantic version string of the SDK
@@ -5,7 +5,7 @@
5
5
  /**
6
6
  * Current version of the enyo Energy App SDK.
7
7
  */
8
- export declare const SDK_VERSION = "0.0.29";
8
+ export declare const SDK_VERSION = "0.0.31";
9
9
  /**
10
10
  * Gets the current SDK version.
11
11
  * @returns The semantic version string of the SDK
@@ -14,9 +14,11 @@ export declare abstract class BaseSunspecDevice {
14
14
  protected readonly sunspecClient: SunspecModbusClient;
15
15
  protected readonly applianceManager: ApplianceManager;
16
16
  protected readonly unitId: number;
17
+ protected readonly port: number;
18
+ protected readonly baseAddress: number;
17
19
  protected applianceId?: string;
18
20
  protected lastUpdateTime: number;
19
- constructor(energyApp: EnergyApp, name: EnyoApplianceName[], networkDevice: EnyoNetworkDevice, sunspecClient: SunspecModbusClient, applianceManager: ApplianceManager, unitId?: number);
21
+ constructor(energyApp: EnergyApp, name: EnyoApplianceName[], networkDevice: EnyoNetworkDevice, sunspecClient: SunspecModbusClient, applianceManager: ApplianceManager, unitId?: number, port?: number, baseAddress?: number);
20
22
  /**
21
23
  * Connect to the device and create/update the appliance
22
24
  */
@@ -62,6 +64,8 @@ export declare class SunspecInverter extends BaseSunspecDevice {
62
64
  * Sunspec Battery implementation
63
65
  */
64
66
  export declare class SunspecBattery extends BaseSunspecDevice {
67
+ private dataBusListenerId?;
68
+ private dataBus?;
65
69
  /**
66
70
  * Connect to the battery and create/update the appliance
67
71
  */
@@ -150,6 +154,20 @@ export declare class SunspecBattery extends BaseSunspecDevice {
150
154
  * @returns Promise<boolean | null> - true if enabled, false if disabled, null if error
151
155
  */
152
156
  isGridChargingEnabled(): Promise<boolean | null>;
157
+ /**
158
+ * Start listening for storage commands on the data bus.
159
+ * Idempotent — does nothing if already listening.
160
+ */
161
+ startDataBusListening(): void;
162
+ /**
163
+ * Stop listening for storage commands on the data bus.
164
+ */
165
+ stopDataBusListening(): void;
166
+ private handleStorageCommand;
167
+ private handleStartGridCharge;
168
+ private handleStopGridCharge;
169
+ private handleSetDischargeLimit;
170
+ private sendCommandAcknowledge;
153
171
  }
154
172
  /**
155
173
  * Sunspec Meter implementation
@@ -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
@@ -53,9 +57,9 @@ export class BaseSunspecDevice {
53
57
  async ensureConnected() {
54
58
  if (!this.sunspecClient.isConnected()) {
55
59
  await this.sunspecClient.connect(this.networkDevice.hostname, // primary
56
- 502, this.unitId, this.networkDevice.ipAddress // secondary fallback
60
+ this.port, this.unitId, this.networkDevice.ipAddress // secondary fallback
57
61
  );
58
- await this.sunspecClient.discoverModels();
62
+ await this.sunspecClient.discoverModels(this.baseAddress);
59
63
  }
60
64
  }
61
65
  }
@@ -67,9 +71,9 @@ export class SunspecInverter extends BaseSunspecDevice {
67
71
  // Ensure Sunspec client is connected
68
72
  if (!this.sunspecClient.isConnected()) {
69
73
  await this.sunspecClient.connect(this.networkDevice.hostname, // primary
70
- 502, this.unitId, this.networkDevice.ipAddress // secondary fallback
74
+ this.port, this.unitId, this.networkDevice.ipAddress // secondary fallback
71
75
  );
72
- await this.sunspecClient.discoverModels();
76
+ await this.sunspecClient.discoverModels(this.baseAddress);
73
77
  }
74
78
  // Get device info from common block
75
79
  const commonData = await this.sunspecClient.readCommonBlock();
@@ -240,6 +244,8 @@ export class SunspecInverter extends BaseSunspecDevice {
240
244
  * Sunspec Battery implementation
241
245
  */
242
246
  export class SunspecBattery extends BaseSunspecDevice {
247
+ dataBusListenerId;
248
+ dataBus;
243
249
  /**
244
250
  * Connect to the battery and create/update the appliance
245
251
  */
@@ -283,12 +289,14 @@ export class SunspecBattery extends BaseSunspecDevice {
283
289
  }
284
290
  });
285
291
  console.log(`Sunspec Battery connected: ${this.networkDevice.hostname} (${this.applianceId})`);
292
+ this.startDataBusListening();
286
293
  }
287
294
  catch (error) {
288
295
  console.error(`Failed to create battery appliance: ${error}`);
289
296
  }
290
297
  }
291
298
  async disconnect() {
299
+ this.stopDataBusListening();
292
300
  if (this.applianceId) {
293
301
  await this.applianceManager.updateApplianceState(this.applianceId, EnyoApplianceConnectionType.Connector, EnyoApplianceStateEnum.Offline);
294
302
  }
@@ -557,6 +565,155 @@ export class SunspecBattery extends BaseSunspecDevice {
557
565
  }
558
566
  return controls.chaGriSet === 1; // 1 = GRID, 0 = PV
559
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
+ }
560
717
  }
561
718
  /**
562
719
  * Sunspec Meter implementation
@@ -25,7 +25,6 @@ export declare class SunspecModbusClient {
25
25
  private modbusClient;
26
26
  private discoveredModels;
27
27
  private connected;
28
- private baseAddress;
29
28
  private connectionHealth;
30
29
  private faultTolerantReader;
31
30
  private modbusDataTypeConverter;
@@ -76,7 +75,7 @@ export declare class SunspecModbusClient {
76
75
  /**
77
76
  * Detect the base address and addressing mode (0-based or 1-based) for SunSpec
78
77
  */
79
- detectSunspecBaseAddress(): Promise<{
78
+ detectSunspecBaseAddress(customBaseAddress?: number): Promise<{
80
79
  baseAddress: number;
81
80
  isZeroBased: boolean;
82
81
  nextAddress: number;
@@ -85,7 +84,7 @@ export declare class SunspecModbusClient {
85
84
  * Discover all available Sunspec models
86
85
  * Automatically detects base address (40000 or 40001) and scans from there
87
86
  */
88
- discoverModels(): Promise<Map<number, SunspecModel>>;
87
+ discoverModels(customBaseAddress?: number): Promise<Map<number, SunspecModel>>;
89
88
  /**
90
89
  * Find a specific model by ID
91
90
  */
@@ -26,7 +26,6 @@ export class SunspecModbusClient {
26
26
  modbusClient = null;
27
27
  discoveredModels = new Map();
28
28
  connected = false;
29
- baseAddress = 40001;
30
29
  connectionHealth;
31
30
  faultTolerantReader = null;
32
31
  modbusDataTypeConverter;
@@ -222,49 +221,86 @@ export class SunspecModbusClient {
222
221
  /**
223
222
  * Detect the base address and addressing mode (0-based or 1-based) for SunSpec
224
223
  */
225
- async detectSunspecBaseAddress() {
224
+ async detectSunspecBaseAddress(customBaseAddress) {
226
225
  if (!this.modbusClient) {
227
226
  throw new Error('Modbus client not initialized');
228
227
  }
229
- // Try 1-based addressing first (most common)
230
- try {
231
- const sunspecId = await this.modbusClient.readRegisterStringValue(40001, 2);
232
- if (sunspecId.includes('SunS')) {
233
- console.log('Detected 1-based addressing mode (base address: 40001)');
234
- this.baseAddress = 40001;
235
- return {
236
- baseAddress: 40001,
237
- isZeroBased: false,
238
- nextAddress: 40003
239
- };
228
+ // If custom base address provided, try it first (1-based, then 0-based variant)
229
+ if (customBaseAddress !== undefined) {
230
+ console.log(`Detect models for custom base address '${customBaseAddress}' ...`);
231
+ // Try 0-based at custom address
232
+ try {
233
+ const sunspecId = await this.modbusClient.readRegisterStringValue(customBaseAddress, 2);
234
+ if (sunspecId.includes('SunS')) {
235
+ console.log(`Detected 0-based addressing mode (base address: ${customBaseAddress})`);
236
+ return {
237
+ baseAddress: customBaseAddress,
238
+ isZeroBased: true,
239
+ nextAddress: customBaseAddress + 2
240
+ };
241
+ }
240
242
  }
241
- }
242
- catch (error) {
243
- console.debug('Could not read SunS at 40001:', error);
244
- }
245
- // Try 0-based addressing
246
- try {
247
- const sunspecId = await this.modbusClient.readRegisterStringValue(40000, 2);
248
- if (sunspecId.includes('SunS')) {
249
- console.log('Detected 0-based addressing mode (base address: 40000)');
250
- this.baseAddress = 40000;
251
- return {
252
- baseAddress: 40000,
253
- isZeroBased: true,
254
- nextAddress: 40002
255
- };
243
+ catch (error) {
244
+ console.debug(`Could not read SunS at ${customBaseAddress}:`, error);
245
+ }
246
+ // Try 1-based at custom address (customBaseAddress + 1)
247
+ try {
248
+ const sunspecId = await this.modbusClient.readRegisterStringValue(customBaseAddress + 1, 2);
249
+ if (sunspecId.includes('SunS')) {
250
+ console.log(`Detected 1-based addressing mode (base address: ${customBaseAddress + 1})`);
251
+ return {
252
+ baseAddress: customBaseAddress + 1,
253
+ isZeroBased: false,
254
+ nextAddress: customBaseAddress + 3
255
+ };
256
+ }
257
+ }
258
+ catch (error) {
259
+ console.debug(`Could not read SunS at ${customBaseAddress + 1}:`, error);
256
260
  }
257
261
  }
258
- catch (error) {
259
- console.debug('Could not read SunS at 40000:', error);
262
+ else {
263
+ // Try 1-based addressing first (most common)
264
+ try {
265
+ const sunspecId = await this.modbusClient.readRegisterStringValue(40001, 2);
266
+ if (sunspecId.includes('SunS')) {
267
+ console.log('Detected 1-based addressing mode (base address: 40001)');
268
+ return {
269
+ baseAddress: 40001,
270
+ isZeroBased: false,
271
+ nextAddress: 40003
272
+ };
273
+ }
274
+ }
275
+ catch (error) {
276
+ console.debug('Could not read SunS at 40001:', error);
277
+ }
278
+ // Try 0-based addressing
279
+ try {
280
+ const sunspecId = await this.modbusClient.readRegisterStringValue(40000, 2);
281
+ if (sunspecId.includes('SunS')) {
282
+ console.log('Detected 0-based addressing mode (base address: 40000)');
283
+ return {
284
+ baseAddress: 40000,
285
+ isZeroBased: true,
286
+ nextAddress: 40002
287
+ };
288
+ }
289
+ }
290
+ catch (error) {
291
+ console.debug('Could not read SunS at 40000:', error);
292
+ }
260
293
  }
261
- throw new Error('Device is not SunSpec compliant - "SunS" identifier not found at addresses 40000 or 40001');
294
+ const addressesChecked = customBaseAddress !== undefined
295
+ ? `${customBaseAddress}, ${customBaseAddress + 1}, 40000, or 40001`
296
+ : '40000 or 40001';
297
+ throw new Error(`Device is not SunSpec compliant - "SunS" identifier not found at addresses ${addressesChecked}`);
262
298
  }
263
299
  /**
264
300
  * Discover all available Sunspec models
265
301
  * Automatically detects base address (40000 or 40001) and scans from there
266
302
  */
267
- async discoverModels() {
303
+ async discoverModels(customBaseAddress) {
268
304
  if (!this.connected) {
269
305
  throw new Error('Not connected to Modbus device');
270
306
  }
@@ -274,7 +310,7 @@ export class SunspecModbusClient {
274
310
  console.log('Starting Sunspec model discovery...');
275
311
  try {
276
312
  // Detect the base address and addressing mode
277
- const addressInfo = await this.detectSunspecBaseAddress();
313
+ const addressInfo = await this.detectSunspecBaseAddress(customBaseAddress);
278
314
  currentAddress = addressInfo.nextAddress;
279
315
  while (currentAddress < maxAddress) {
280
316
  // Read model ID and length
package/dist/version.d.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  /**
6
6
  * Current version of the enyo Energy App SDK.
7
7
  */
8
- export declare const SDK_VERSION = "0.0.29";
8
+ export declare const SDK_VERSION = "0.0.31";
9
9
  /**
10
10
  * Gets the current SDK version.
11
11
  * @returns The semantic version string of the SDK
package/dist/version.js CHANGED
@@ -5,7 +5,7 @@
5
5
  /**
6
6
  * Current version of the enyo Energy App SDK.
7
7
  */
8
- export const SDK_VERSION = '0.0.29';
8
+ export const SDK_VERSION = '0.0.31';
9
9
  /**
10
10
  * Gets the current SDK version.
11
11
  * @returns The semantic version string of the SDK
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enyo-energy/sunspec-sdk",
3
- "version": "0.0.29",
3
+ "version": "0.0.31",
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.58"
40
+ "@enyo-energy/energy-app-sdk": "^0.0.60"
41
41
  },
42
42
  "volta": {
43
43
  "node": "22.17.0"