@enyo-energy/sunspec-sdk 0.0.69 → 0.0.71

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.
@@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
3
3
  import { EnyoApplianceConnectionType, EnyoApplianceStateEnum, EnyoApplianceStatusEnum, EnyoApplianceTopologyFeatureEnum, EnyoApplianceTypeEnum } from "@enyo-energy/energy-app-sdk/dist/types/enyo-appliance.js";
4
4
  import { ConnectionRetryManager } from "./connection-retry-manager.js";
5
5
  import { EnyoBatteryStateEnum, EnyoCommandAcknowledgeAnswerEnum, EnyoDataBusMessageEnum, EnyoInverterStateEnum, EnyoStringStateEnum } from "@enyo-energy/energy-app-sdk/dist/types/enyo-data-bus-value.js";
6
+ import { CalibrationSnapshotService } from "./calibration-snapshot-service.js";
6
7
  import { EnyoSourceEnum } from "@enyo-energy/energy-app-sdk/dist/types/enyo-source.enum.js";
7
8
  import { EnyoMeterApplianceAvailableFeaturesEnum } from "@enyo-energy/energy-app-sdk/dist/types/enyo-meter-appliance.js";
8
9
  import { EnyoBatteryFeature, EnyoBatteryStorageMode } from "@enyo-energy/energy-app-sdk/dist/types/enyo-battery-appliance.js";
@@ -96,6 +97,7 @@ export class BaseSunspecDevice {
96
97
  dataBus;
97
98
  retryManager;
98
99
  consecutiveReconnectFailures = 0;
100
+ calibrationService;
99
101
  constructor(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId = 1, port = 502, baseAddress = 40000, retryConfig, appliance, useTls) {
100
102
  this.energyApp = energyApp;
101
103
  this.name = name;
@@ -249,6 +251,27 @@ export class BaseSunspecDevice {
249
251
  console.log(`${this.constructor.name} ${this.applianceId}: sending ${answer} for ${acknowledgeMessage} (messageId=${messageId}${rejectionReason ? `, reason=${rejectionReason}` : ''})`);
250
252
  this.dataBus.sendMessage([ackMessage]);
251
253
  }
254
+ /**
255
+ * Lazily construct and initialize the per-appliance CalibrationSnapshotService. Reloads
256
+ * any persisted snapshot from storage so an in-flight calibration survives restarts.
257
+ * Idempotent — subsequent calls are no-ops once the service exists.
258
+ */
259
+ async initCalibrationService(deviceType) {
260
+ if (this.calibrationService || !this.applianceId) {
261
+ return;
262
+ }
263
+ this.calibrationService = new CalibrationSnapshotService(this.energyApp.useStorage(), this.applianceId, (snapshot, reason) => this.restoreFromCalibrationSnapshot(snapshot, reason));
264
+ await this.calibrationService.initialize();
265
+ console.log(`${this.constructor.name} ${this.applianceId}: calibration service initialized (${deviceType})`);
266
+ }
267
+ /**
268
+ * Subclasses implement how to write the modified fields of a snapshot back to the device.
269
+ * Called both on explicit StopCalibrationV1 and when the 5-minute auto-stop fires.
270
+ */
271
+ async restoreFromCalibrationSnapshot(_snapshot, _reason) {
272
+ // Default: subclasses override. Base implementation is a no-op so meter and other
273
+ // non-controllable devices don't need to implement it.
274
+ }
252
275
  }
253
276
  /**
254
277
  * Sunspec Inverter implementation using dynamic model discovery
@@ -671,8 +694,16 @@ export class SunspecInverter extends BaseSunspecDevice {
671
694
  if (this.dataBusListenerId) {
672
695
  return;
673
696
  }
697
+ // Fire and forget — calibration service hydration must not block listening setup.
698
+ // Listener registration before await ensures we don't miss messages that arrive
699
+ // during initial load.
700
+ void this.initCalibrationService('inverter');
674
701
  this.dataBus = this.energyApp.useDataBus();
675
- this.dataBusListenerId = this.dataBus.listenForMessages([EnyoDataBusMessageEnum.SetInverterFeedInLimitV1], (entry) => this.handleInverterCommand(entry));
702
+ this.dataBusListenerId = this.dataBus.listenForMessages([
703
+ EnyoDataBusMessageEnum.SetInverterFeedInLimitV1,
704
+ EnyoDataBusMessageEnum.StartCalibrationV1,
705
+ EnyoDataBusMessageEnum.StopCalibrationV1,
706
+ ], (entry) => this.handleInverterCommand(entry));
676
707
  console.log(`Inverter ${this.applianceId}: started data bus listening (listener ${this.dataBusListenerId})`);
677
708
  }
678
709
  /**
@@ -692,8 +723,16 @@ export class SunspecInverter extends BaseSunspecDevice {
692
723
  }
693
724
  void (async () => {
694
725
  try {
695
- if (entry.message === EnyoDataBusMessageEnum.SetInverterFeedInLimitV1) {
696
- await this.handleSetFeedInLimit(entry);
726
+ switch (entry.message) {
727
+ case EnyoDataBusMessageEnum.SetInverterFeedInLimitV1:
728
+ await this.handleSetFeedInLimit(entry);
729
+ break;
730
+ case EnyoDataBusMessageEnum.StartCalibrationV1:
731
+ await this.handleStartCalibration(entry);
732
+ break;
733
+ case EnyoDataBusMessageEnum.StopCalibrationV1:
734
+ await this.handleStopCalibration(entry);
735
+ break;
697
736
  }
698
737
  }
699
738
  catch (error) {
@@ -717,8 +756,93 @@ export class SunspecInverter extends BaseSunspecDevice {
717
756
  this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to set feed-in limit');
718
757
  return;
719
758
  }
759
+ // setFeedInLimit writes WMaxLimPct + WMaxLim_Ena (see SunspecModbusClient.setFeedInLimit).
760
+ // Record both so an active calibration can roll them back on stop.
761
+ await this.calibrationService?.recordModification(['WMaxLimPct', 'WMaxLim_Ena']);
720
762
  this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Accepted);
721
763
  }
764
+ async handleStartCalibration(msg) {
765
+ if (!this.isConnected() || !this.applianceId) {
766
+ this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Not connected');
767
+ return;
768
+ }
769
+ if (!this.calibrationService) {
770
+ await this.initCalibrationService('inverter');
771
+ }
772
+ if (!this.calibrationService) {
773
+ this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Calibration service unavailable');
774
+ return;
775
+ }
776
+ if (this.calibrationService.isCalibrating()) {
777
+ console.log(`Inverter ${this.applianceId}: calibration already active — ack idempotently`);
778
+ this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Accepted);
779
+ return;
780
+ }
781
+ console.log(`Inverter ${this.applianceId}: handling StartCalibrationV1`);
782
+ const controls = await this.sunspecClient.readInverterControls(this.unitId);
783
+ if (!controls) {
784
+ this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to read inverter controls for snapshot');
785
+ return;
786
+ }
787
+ try {
788
+ await this.calibrationService.startCalibration({
789
+ deviceType: 'inverter',
790
+ unitId: this.unitId,
791
+ inverterControls: controls,
792
+ });
793
+ }
794
+ catch (error) {
795
+ this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, `Failed to persist snapshot: ${error}`);
796
+ return;
797
+ }
798
+ this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Accepted);
799
+ }
800
+ async handleStopCalibration(msg) {
801
+ if (!this.applianceId) {
802
+ this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Not initialized');
803
+ return;
804
+ }
805
+ console.log(`Inverter ${this.applianceId}: handling StopCalibrationV1`);
806
+ const snapshot = await this.calibrationService?.stopCalibration();
807
+ if (!snapshot) {
808
+ // No active calibration — treat as a no-op success.
809
+ this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Accepted);
810
+ return;
811
+ }
812
+ try {
813
+ await this.restoreFromCalibrationSnapshot(snapshot, 'stop');
814
+ }
815
+ catch (error) {
816
+ this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, `Failed to restore registers: ${error}`);
817
+ return;
818
+ }
819
+ this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Accepted);
820
+ }
821
+ async restoreFromCalibrationSnapshot(snapshot, reason) {
822
+ if (snapshot.deviceType !== 'inverter' || !snapshot.inverterControls) {
823
+ return;
824
+ }
825
+ const source = snapshot.inverterControls;
826
+ const partial = {};
827
+ // Whitelist of inverter fields that writeInverterControls actually writes (Model 123).
828
+ // Iterating a typed const list keeps the projection type-safe and bounds the rollback
829
+ // to fields the SDK knows how to restore.
830
+ const WRITABLE_INVERTER_FIELDS = ['Conn', 'WMaxLimPct', 'WMaxLim_Ena', 'OutPFSet', 'OutPFSet_Ena'];
831
+ for (const field of WRITABLE_INVERTER_FIELDS) {
832
+ if (snapshot.modifiedFields.includes(field) && source[field] !== undefined) {
833
+ partial[field] = source[field];
834
+ }
835
+ }
836
+ if (Object.keys(partial).length === 0) {
837
+ console.log(`Inverter ${this.applianceId}: calibration ${reason} — no modified fields to restore`);
838
+ return;
839
+ }
840
+ console.log(`Inverter ${this.applianceId}: calibration ${reason} — restoring [${Object.keys(partial).join(', ')}]`);
841
+ const ok = await this.sunspecClient.writeInverterControls(snapshot.unitId, partial);
842
+ if (!ok) {
843
+ throw new Error('writeInverterControls returned false');
844
+ }
845
+ }
722
846
  }
723
847
  /**
724
848
  * Sunspec Battery implementation
@@ -1162,12 +1286,16 @@ export class SunspecBattery extends BaseSunspecDevice {
1162
1286
  if (this.dataBusListenerId) {
1163
1287
  return;
1164
1288
  }
1289
+ // Fire and forget — calibration service hydration must not block listening setup.
1290
+ void this.initCalibrationService('battery');
1165
1291
  this.dataBus = this.energyApp.useDataBus();
1166
1292
  this.dataBusListenerId = this.dataBus.listenForMessages([
1167
1293
  EnyoDataBusMessageEnum.StartStorageGridChargeV1,
1168
1294
  EnyoDataBusMessageEnum.StopStorageGridChargeV1,
1169
1295
  EnyoDataBusMessageEnum.SetStorageDischargeLimitV1,
1170
- EnyoDataBusMessageEnum.SetStorageChargeLimitV1
1296
+ EnyoDataBusMessageEnum.SetStorageChargeLimitV1,
1297
+ EnyoDataBusMessageEnum.StartCalibrationV1,
1298
+ EnyoDataBusMessageEnum.StopCalibrationV1,
1171
1299
  ], (entry) => this.handleStorageCommand(entry));
1172
1300
  console.log(`Battery ${this.applianceId}: started data bus listening (listener ${this.dataBusListenerId})`);
1173
1301
  }
@@ -1201,6 +1329,12 @@ export class SunspecBattery extends BaseSunspecDevice {
1201
1329
  case EnyoDataBusMessageEnum.SetStorageChargeLimitV1:
1202
1330
  await this.handleSetChargeLimit(entry);
1203
1331
  break;
1332
+ case EnyoDataBusMessageEnum.StartCalibrationV1:
1333
+ await this.handleStartCalibration(entry);
1334
+ break;
1335
+ case EnyoDataBusMessageEnum.StopCalibrationV1:
1336
+ await this.handleStopCalibration(entry);
1337
+ break;
1204
1338
  }
1205
1339
  }
1206
1340
  catch (error) {
@@ -1241,6 +1375,7 @@ export class SunspecBattery extends BaseSunspecDevice {
1241
1375
  this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to set storage mode to charge');
1242
1376
  return;
1243
1377
  }
1378
+ await this.calibrationService?.recordModification(['chaGriSet', 'wChaMax', 'storCtlMod']);
1244
1379
  this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Accepted);
1245
1380
  }
1246
1381
  async handleStopGridCharge(msg) {
@@ -1267,6 +1402,7 @@ export class SunspecBattery extends BaseSunspecDevice {
1267
1402
  this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to set storage mode to auto');
1268
1403
  return;
1269
1404
  }
1405
+ await this.calibrationService?.recordModification(['chaGriSet', 'storCtlMod']);
1270
1406
  this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Accepted);
1271
1407
  }
1272
1408
  async handleSetDischargeLimit(msg) {
@@ -1291,6 +1427,7 @@ export class SunspecBattery extends BaseSunspecDevice {
1291
1427
  this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to set discharge limit');
1292
1428
  return;
1293
1429
  }
1430
+ await this.calibrationService?.recordModification(['outWRte']);
1294
1431
  this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Accepted);
1295
1432
  }
1296
1433
  async handleSetChargeLimit(msg) {
@@ -1315,8 +1452,88 @@ export class SunspecBattery extends BaseSunspecDevice {
1315
1452
  this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to set charge limit');
1316
1453
  return;
1317
1454
  }
1455
+ await this.calibrationService?.recordModification(['inWRte']);
1318
1456
  this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Accepted);
1319
1457
  }
1458
+ async handleStartCalibration(msg) {
1459
+ if (!this.isConnected() || !this.applianceId) {
1460
+ this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Not connected');
1461
+ return;
1462
+ }
1463
+ if (!this.calibrationService) {
1464
+ await this.initCalibrationService('battery');
1465
+ }
1466
+ if (!this.calibrationService) {
1467
+ this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Calibration service unavailable');
1468
+ return;
1469
+ }
1470
+ if (this.calibrationService.isCalibrating()) {
1471
+ console.log(`Battery ${this.applianceId}: calibration already active — ack idempotently`);
1472
+ this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Accepted);
1473
+ return;
1474
+ }
1475
+ console.log(`Battery ${this.applianceId}: handling StartCalibrationV1`);
1476
+ const controls = await this.sunspecClient.readBatteryControls(this.unitId);
1477
+ if (!controls) {
1478
+ this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to read battery controls for snapshot');
1479
+ return;
1480
+ }
1481
+ try {
1482
+ await this.calibrationService.startCalibration({
1483
+ deviceType: 'battery',
1484
+ unitId: this.unitId,
1485
+ batteryControls: controls,
1486
+ });
1487
+ }
1488
+ catch (error) {
1489
+ this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, `Failed to persist snapshot: ${error}`);
1490
+ return;
1491
+ }
1492
+ this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Accepted);
1493
+ }
1494
+ async handleStopCalibration(msg) {
1495
+ if (!this.applianceId) {
1496
+ this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Not initialized');
1497
+ return;
1498
+ }
1499
+ console.log(`Battery ${this.applianceId}: handling StopCalibrationV1`);
1500
+ const snapshot = await this.calibrationService?.stopCalibration();
1501
+ if (!snapshot) {
1502
+ this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Accepted);
1503
+ return;
1504
+ }
1505
+ try {
1506
+ await this.restoreFromCalibrationSnapshot(snapshot, 'stop');
1507
+ }
1508
+ catch (error) {
1509
+ this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, `Failed to restore registers: ${error}`);
1510
+ return;
1511
+ }
1512
+ this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Accepted);
1513
+ }
1514
+ async restoreFromCalibrationSnapshot(snapshot, reason) {
1515
+ if (snapshot.deviceType !== 'battery' || !snapshot.batteryControls) {
1516
+ return;
1517
+ }
1518
+ const source = snapshot.batteryControls;
1519
+ const partial = {};
1520
+ // Whitelist of battery fields that writeBatteryControls actually writes (Model 124).
1521
+ const WRITABLE_BATTERY_FIELDS = ['storCtlMod', 'chaGriSet', 'wChaMax', 'inWRte', 'outWRte', 'minRsvPct'];
1522
+ for (const field of WRITABLE_BATTERY_FIELDS) {
1523
+ if (snapshot.modifiedFields.includes(field) && source[field] !== undefined) {
1524
+ partial[field] = source[field];
1525
+ }
1526
+ }
1527
+ if (Object.keys(partial).length === 0) {
1528
+ console.log(`Battery ${this.applianceId}: calibration ${reason} — no modified fields to restore`);
1529
+ return;
1530
+ }
1531
+ console.log(`Battery ${this.applianceId}: calibration ${reason} — restoring [${Object.keys(partial).join(', ')}]`);
1532
+ const ok = await this.sunspecClient.writeBatteryControls(snapshot.unitId, partial);
1533
+ if (!ok) {
1534
+ throw new Error('writeBatteryControls returned false');
1535
+ }
1536
+ }
1320
1537
  }
1321
1538
  /**
1322
1539
  * Sunspec Meter implementation
@@ -1391,11 +1608,13 @@ export class SunspecMeter extends BaseSunspecDevice {
1391
1608
  console.error(`Failed to update meter appliance: ${error}`);
1392
1609
  }
1393
1610
  }
1611
+ this.startDataBusListening();
1394
1612
  }
1395
1613
  /**
1396
1614
  * Disconnect from the meter and update appliance state
1397
1615
  */
1398
1616
  async disconnect() {
1617
+ this.stopDataBusListening();
1399
1618
  if (this.applianceId) {
1400
1619
  try {
1401
1620
  await this.applianceManager.updateApplianceState(this.applianceId, EnyoApplianceConnectionType.Connector, EnyoApplianceStateEnum.Offline);
@@ -1407,6 +1626,35 @@ export class SunspecMeter extends BaseSunspecDevice {
1407
1626
  // Close just this meter's unit; other devices on the same network device stay open.
1408
1627
  await this.sunspecClient.disconnectUnit(this.unitId);
1409
1628
  }
1629
+ /**
1630
+ * Meter does not implement calibration; it only subscribes to Start/StopCalibrationV1 to
1631
+ * answer NotSupported (per the data-bus contract that every command must be acknowledged).
1632
+ */
1633
+ startDataBusListening() {
1634
+ if (this.dataBusListenerId) {
1635
+ return;
1636
+ }
1637
+ this.dataBus = this.energyApp.useDataBus();
1638
+ this.dataBusListenerId = this.dataBus.listenForMessages([
1639
+ EnyoDataBusMessageEnum.StartCalibrationV1,
1640
+ EnyoDataBusMessageEnum.StopCalibrationV1,
1641
+ ], (entry) => this.handleMeterCommand(entry));
1642
+ console.log(`Meter ${this.applianceId}: started data bus listening (listener ${this.dataBusListenerId})`);
1643
+ }
1644
+ stopDataBusListening() {
1645
+ if (this.dataBusListenerId && this.dataBus) {
1646
+ this.dataBus.unsubscribe(this.dataBusListenerId);
1647
+ console.log(`Meter ${this.applianceId}: stopped data bus listening (listener ${this.dataBusListenerId})`);
1648
+ }
1649
+ this.dataBusListenerId = undefined;
1650
+ this.dataBus = undefined;
1651
+ }
1652
+ handleMeterCommand(entry) {
1653
+ if (entry.applianceId !== this.applianceId) {
1654
+ return;
1655
+ }
1656
+ this.sendCommandAcknowledge(entry.id, entry.message, EnyoCommandAcknowledgeAnswerEnum.NotSupported, 'Meter does not support calibration');
1657
+ }
1410
1658
  /**
1411
1659
  * Update meter data and return data bus messages
1412
1660
  */
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.69";
8
+ export declare const SDK_VERSION = "0.0.71";
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.69';
8
+ export const SDK_VERSION = '0.0.71';
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.69",
3
+ "version": "0.0.71",
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.135"
40
+ "@enyo-energy/energy-app-sdk": "^0.0.137"
41
41
  },
42
42
  "volta": {
43
43
  "node": "22.17.0"