@enyo-energy/sunspec-sdk 0.0.29 → 0.0.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,16 +224,48 @@ 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
  }
231
+ // If custom base address provided, try it first (1-based, then 0-based variant)
232
+ if (customBaseAddress !== undefined) {
233
+ // Try 1-based at custom address (customBaseAddress + 1)
234
+ try {
235
+ const sunspecId = await this.modbusClient.readRegisterStringValue(customBaseAddress + 1, 2);
236
+ if (sunspecId.includes('SunS')) {
237
+ console.log(`Detected 1-based addressing mode (base address: ${customBaseAddress + 1})`);
238
+ return {
239
+ baseAddress: customBaseAddress + 1,
240
+ isZeroBased: false,
241
+ nextAddress: customBaseAddress + 3
242
+ };
243
+ }
244
+ }
245
+ catch (error) {
246
+ console.debug(`Could not read SunS at ${customBaseAddress + 1}:`, error);
247
+ }
248
+ // Try 0-based at custom address
249
+ try {
250
+ const sunspecId = await this.modbusClient.readRegisterStringValue(customBaseAddress, 2);
251
+ if (sunspecId.includes('SunS')) {
252
+ console.log(`Detected 0-based addressing mode (base address: ${customBaseAddress})`);
253
+ return {
254
+ baseAddress: customBaseAddress,
255
+ isZeroBased: true,
256
+ nextAddress: customBaseAddress + 2
257
+ };
258
+ }
259
+ }
260
+ catch (error) {
261
+ console.debug(`Could not read SunS at ${customBaseAddress}:`, error);
262
+ }
263
+ }
232
264
  // Try 1-based addressing first (most common)
233
265
  try {
234
266
  const sunspecId = await this.modbusClient.readRegisterStringValue(40001, 2);
235
267
  if (sunspecId.includes('SunS')) {
236
268
  console.log('Detected 1-based addressing mode (base address: 40001)');
237
- this.baseAddress = 40001;
238
269
  return {
239
270
  baseAddress: 40001,
240
271
  isZeroBased: false,
@@ -250,7 +281,6 @@ class SunspecModbusClient {
250
281
  const sunspecId = await this.modbusClient.readRegisterStringValue(40000, 2);
251
282
  if (sunspecId.includes('SunS')) {
252
283
  console.log('Detected 0-based addressing mode (base address: 40000)');
253
- this.baseAddress = 40000;
254
284
  return {
255
285
  baseAddress: 40000,
256
286
  isZeroBased: true,
@@ -261,13 +291,16 @@ class SunspecModbusClient {
261
291
  catch (error) {
262
292
  console.debug('Could not read SunS at 40000:', error);
263
293
  }
264
- 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}`);
265
298
  }
266
299
  /**
267
300
  * Discover all available Sunspec models
268
301
  * Automatically detects base address (40000 or 40001) and scans from there
269
302
  */
270
- async discoverModels() {
303
+ async discoverModels(customBaseAddress) {
271
304
  if (!this.connected) {
272
305
  throw new Error('Not connected to Modbus device');
273
306
  }
@@ -277,7 +310,7 @@ class SunspecModbusClient {
277
310
  console.log('Starting Sunspec model discovery...');
278
311
  try {
279
312
  // Detect the base address and addressing mode
280
- const addressInfo = await this.detectSunspecBaseAddress();
313
+ const addressInfo = await this.detectSunspecBaseAddress(customBaseAddress);
281
314
  currentAddress = addressInfo.nextAddress;
282
315
  while (currentAddress < maxAddress) {
283
316
  // 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.30';
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.30";
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,16 +221,48 @@ 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
  }
228
+ // If custom base address provided, try it first (1-based, then 0-based variant)
229
+ if (customBaseAddress !== undefined) {
230
+ // Try 1-based at custom address (customBaseAddress + 1)
231
+ try {
232
+ const sunspecId = await this.modbusClient.readRegisterStringValue(customBaseAddress + 1, 2);
233
+ if (sunspecId.includes('SunS')) {
234
+ console.log(`Detected 1-based addressing mode (base address: ${customBaseAddress + 1})`);
235
+ return {
236
+ baseAddress: customBaseAddress + 1,
237
+ isZeroBased: false,
238
+ nextAddress: customBaseAddress + 3
239
+ };
240
+ }
241
+ }
242
+ catch (error) {
243
+ console.debug(`Could not read SunS at ${customBaseAddress + 1}:`, error);
244
+ }
245
+ // Try 0-based at custom address
246
+ try {
247
+ const sunspecId = await this.modbusClient.readRegisterStringValue(customBaseAddress, 2);
248
+ if (sunspecId.includes('SunS')) {
249
+ console.log(`Detected 0-based addressing mode (base address: ${customBaseAddress})`);
250
+ return {
251
+ baseAddress: customBaseAddress,
252
+ isZeroBased: true,
253
+ nextAddress: customBaseAddress + 2
254
+ };
255
+ }
256
+ }
257
+ catch (error) {
258
+ console.debug(`Could not read SunS at ${customBaseAddress}:`, error);
259
+ }
260
+ }
229
261
  // Try 1-based addressing first (most common)
230
262
  try {
231
263
  const sunspecId = await this.modbusClient.readRegisterStringValue(40001, 2);
232
264
  if (sunspecId.includes('SunS')) {
233
265
  console.log('Detected 1-based addressing mode (base address: 40001)');
234
- this.baseAddress = 40001;
235
266
  return {
236
267
  baseAddress: 40001,
237
268
  isZeroBased: false,
@@ -247,7 +278,6 @@ export class SunspecModbusClient {
247
278
  const sunspecId = await this.modbusClient.readRegisterStringValue(40000, 2);
248
279
  if (sunspecId.includes('SunS')) {
249
280
  console.log('Detected 0-based addressing mode (base address: 40000)');
250
- this.baseAddress = 40000;
251
281
  return {
252
282
  baseAddress: 40000,
253
283
  isZeroBased: true,
@@ -258,13 +288,16 @@ export class SunspecModbusClient {
258
288
  catch (error) {
259
289
  console.debug('Could not read SunS at 40000:', error);
260
290
  }
261
- throw new Error('Device is not SunSpec compliant - "SunS" identifier not found at addresses 40000 or 40001');
291
+ const addressesChecked = customBaseAddress !== undefined
292
+ ? `${customBaseAddress}, ${customBaseAddress + 1}, 40000, or 40001`
293
+ : '40000 or 40001';
294
+ throw new Error(`Device is not SunSpec compliant - "SunS" identifier not found at addresses ${addressesChecked}`);
262
295
  }
263
296
  /**
264
297
  * Discover all available Sunspec models
265
298
  * Automatically detects base address (40000 or 40001) and scans from there
266
299
  */
267
- async discoverModels() {
300
+ async discoverModels(customBaseAddress) {
268
301
  if (!this.connected) {
269
302
  throw new Error('Not connected to Modbus device');
270
303
  }
@@ -274,7 +307,7 @@ export class SunspecModbusClient {
274
307
  console.log('Starting Sunspec model discovery...');
275
308
  try {
276
309
  // Detect the base address and addressing mode
277
- const addressInfo = await this.detectSunspecBaseAddress();
310
+ const addressInfo = await this.detectSunspecBaseAddress(customBaseAddress);
278
311
  currentAddress = addressInfo.nextAddress;
279
312
  while (currentAddress < maxAddress) {
280
313
  // 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.30";
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.30';
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.30",
4
4
  "description": "enyo Energy Sunspec SDK",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -37,7 +37,7 @@
37
37
  "typescript": "^5.8.3"
38
38
  },
39
39
  "dependencies": {
40
- "@enyo-energy/energy-app-sdk": "^0.0.58"
40
+ "@enyo-energy/energy-app-sdk": "^0.0.60"
41
41
  },
42
42
  "volta": {
43
43
  "node": "22.17.0"