@enyo-energy/sunspec-sdk 0.0.71 → 0.0.72

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.
Files changed (34) hide show
  1. package/README.md +302 -0
  2. package/dist/cjs/index.cjs +30 -2
  3. package/dist/cjs/index.d.cts +6 -1
  4. package/dist/cjs/sunspec-battery-calibration-driver.cjs +158 -0
  5. package/dist/cjs/sunspec-battery-calibration-driver.d.cts +63 -0
  6. package/dist/cjs/sunspec-battery-feature-calibrator.cjs +350 -0
  7. package/dist/cjs/sunspec-battery-feature-calibrator.d.cts +89 -0
  8. package/dist/cjs/sunspec-battery-schedule-handler.cjs +92 -0
  9. package/dist/cjs/sunspec-battery-schedule-handler.d.cts +67 -0
  10. package/dist/cjs/sunspec-calibration-storage.cjs +47 -0
  11. package/dist/cjs/sunspec-calibration-storage.d.cts +24 -0
  12. package/dist/cjs/sunspec-devices.cjs +270 -215
  13. package/dist/cjs/sunspec-devices.d.cts +99 -19
  14. package/dist/cjs/sunspec-interfaces.cjs +42 -1
  15. package/dist/cjs/sunspec-interfaces.d.cts +66 -0
  16. package/dist/cjs/version.cjs +1 -1
  17. package/dist/cjs/version.d.cts +1 -1
  18. package/dist/index.d.ts +6 -1
  19. package/dist/index.js +12 -1
  20. package/dist/sunspec-battery-calibration-driver.d.ts +63 -0
  21. package/dist/sunspec-battery-calibration-driver.js +154 -0
  22. package/dist/sunspec-battery-feature-calibrator.d.ts +89 -0
  23. package/dist/sunspec-battery-feature-calibrator.js +345 -0
  24. package/dist/sunspec-battery-schedule-handler.d.ts +67 -0
  25. package/dist/sunspec-battery-schedule-handler.js +88 -0
  26. package/dist/sunspec-calibration-storage.d.ts +24 -0
  27. package/dist/sunspec-calibration-storage.js +42 -0
  28. package/dist/sunspec-devices.d.ts +99 -19
  29. package/dist/sunspec-devices.js +271 -216
  30. package/dist/sunspec-interfaces.d.ts +66 -0
  31. package/dist/sunspec-interfaces.js +41 -0
  32. package/dist/version.d.ts +1 -1
  33. package/dist/version.js +1 -1
  34. package/package.json +7 -3
@@ -6,7 +6,11 @@ const node_crypto_1 = require("node:crypto");
6
6
  const enyo_appliance_js_1 = require("@enyo-energy/energy-app-sdk/dist/types/enyo-appliance.js");
7
7
  const connection_retry_manager_js_1 = require("./connection-retry-manager.cjs");
8
8
  const enyo_data_bus_value_js_1 = require("@enyo-energy/energy-app-sdk/dist/types/enyo-data-bus-value.js");
9
- const calibration_snapshot_service_js_1 = require("./calibration-snapshot-service.cjs");
9
+ const appliance_calibration_1 = require("@enyo-energy/appliance-calibration");
10
+ const sunspec_calibration_storage_js_1 = require("./sunspec-calibration-storage.cjs");
11
+ const sunspec_battery_calibration_driver_js_1 = require("./sunspec-battery-calibration-driver.cjs");
12
+ const sunspec_battery_schedule_handler_js_1 = require("./sunspec-battery-schedule-handler.cjs");
13
+ const sunspec_battery_feature_calibrator_js_1 = require("./sunspec-battery-feature-calibrator.cjs");
10
14
  const enyo_source_enum_js_1 = require("@enyo-energy/energy-app-sdk/dist/types/enyo-source.enum.js");
11
15
  const enyo_meter_appliance_js_1 = require("@enyo-energy/energy-app-sdk/dist/types/enyo-meter-appliance.js");
12
16
  const enyo_battery_appliance_js_1 = require("@enyo-energy/energy-app-sdk/dist/types/enyo-battery-appliance.js");
@@ -100,7 +104,13 @@ class BaseSunspecDevice {
100
104
  dataBus;
101
105
  retryManager;
102
106
  consecutiveReconnectFailures = 0;
103
- calibrationService;
107
+ /**
108
+ * Prefix used when persisting calibration snapshots via the library's
109
+ * {@link SnapshotService}. Kept identical to the key the SDK used before
110
+ * the migration to `@enyo-energy/appliance-calibration` so existing
111
+ * snapshots stored by older builds are still picked up on startup.
112
+ */
113
+ static CALIBRATION_SNAPSHOT_KEY_PREFIX = "sunspec-calibration-snapshot-";
104
114
  constructor(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId = 1, port = 502, baseAddress = 40000, retryConfig, appliance, useTls) {
105
115
  this.energyApp = energyApp;
106
116
  this.name = name;
@@ -255,25 +265,18 @@ class BaseSunspecDevice {
255
265
  this.dataBus.sendMessage([ackMessage]);
256
266
  }
257
267
  /**
258
- * Lazily construct and initialize the per-appliance CalibrationSnapshotService. Reloads
259
- * any persisted snapshot from storage so an in-flight calibration survives restarts.
260
- * Idempotent subsequent calls are no-ops once the service exists.
268
+ * Build a typed {@link SnapshotService} bound to this appliance's calibration storage,
269
+ * using the legacy SDK storage key prefix so prior installs upgrade seamlessly. Returns
270
+ * undefined if the appliance has not been registered yet.
261
271
  */
262
- async initCalibrationService(deviceType) {
263
- if (this.calibrationService || !this.applianceId) {
264
- return;
272
+ buildSnapshotService(onRestore) {
273
+ if (!this.applianceId) {
274
+ return undefined;
265
275
  }
266
- this.calibrationService = new calibration_snapshot_service_js_1.CalibrationSnapshotService(this.energyApp.useStorage(), this.applianceId, (snapshot, reason) => this.restoreFromCalibrationSnapshot(snapshot, reason));
267
- await this.calibrationService.initialize();
268
- console.log(`${this.constructor.name} ${this.applianceId}: calibration service initialized (${deviceType})`);
269
- }
270
- /**
271
- * Subclasses implement how to write the modified fields of a snapshot back to the device.
272
- * Called both on explicit StopCalibrationV1 and when the 5-minute auto-stop fires.
273
- */
274
- async restoreFromCalibrationSnapshot(_snapshot, _reason) {
275
- // Default: subclasses override. Base implementation is a no-op so meter and other
276
- // non-controllable devices don't need to implement it.
276
+ const storage = new sunspec_calibration_storage_js_1.SunspecCalibrationStorage(this.energyApp.useStorage());
277
+ return new appliance_calibration_1.SnapshotService(storage, this.applianceId, onRestore, {
278
+ storageKeyPrefix: BaseSunspecDevice.CALIBRATION_SNAPSHOT_KEY_PREFIX,
279
+ });
277
280
  }
278
281
  }
279
282
  exports.BaseSunspecDevice = BaseSunspecDevice;
@@ -286,6 +289,7 @@ class SunspecInverter extends BaseSunspecDevice {
286
289
  static CONNECTION_FAULT_THRESHOLD = 3;
287
290
  storage;
288
291
  errorState = { activeCodes: [], lastStatus: 'healthy' };
292
+ snapshotService;
289
293
  constructor(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId = 1, port = 502, baseAddress = 40000, capabilities = [], retryConfig, appliance, useTls) {
290
294
  super(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId, port, baseAddress, retryConfig, appliance, useTls);
291
295
  this.capabilities = capabilities;
@@ -698,10 +702,10 @@ class SunspecInverter extends BaseSunspecDevice {
698
702
  if (this.dataBusListenerId) {
699
703
  return;
700
704
  }
701
- // Fire and forget — calibration service hydration must not block listening setup.
705
+ // Fire and forget — snapshot service hydration must not block listener setup.
702
706
  // Listener registration before await ensures we don't miss messages that arrive
703
- // during initial load.
704
- void this.initCalibrationService('inverter');
707
+ // during the initial load.
708
+ void this.initSnapshotService();
705
709
  this.dataBus = this.energyApp.useDataBus();
706
710
  this.dataBusListenerId = this.dataBus.listenForMessages([
707
711
  enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.SetInverterFeedInLimitV1,
@@ -762,22 +766,39 @@ class SunspecInverter extends BaseSunspecDevice {
762
766
  }
763
767
  // setFeedInLimit writes WMaxLimPct + WMaxLim_Ena (see SunspecModbusClient.setFeedInLimit).
764
768
  // Record both so an active calibration can roll them back on stop.
765
- await this.calibrationService?.recordModification(['WMaxLimPct', 'WMaxLim_Ena']);
769
+ await this.snapshotService?.recordModification(['WMaxLimPct', 'WMaxLim_Ena']);
766
770
  this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Accepted);
767
771
  }
772
+ /**
773
+ * Lazily construct the per-appliance {@link SnapshotService}. Reloads any
774
+ * persisted snapshot from storage so an in-flight calibration survives
775
+ * process restarts. Idempotent.
776
+ */
777
+ async initSnapshotService() {
778
+ if (this.snapshotService) {
779
+ return;
780
+ }
781
+ const service = this.buildSnapshotService((snapshot, reason) => this.restoreInverterSnapshot(snapshot, reason));
782
+ if (!service) {
783
+ return;
784
+ }
785
+ this.snapshotService = service;
786
+ await this.snapshotService.initialize();
787
+ console.log(`Inverter ${this.applianceId}: snapshot service initialized`);
788
+ }
768
789
  async handleStartCalibration(msg) {
769
790
  if (!this.isConnected() || !this.applianceId) {
770
791
  this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Not connected');
771
792
  return;
772
793
  }
773
- if (!this.calibrationService) {
774
- await this.initCalibrationService('inverter');
794
+ if (!this.snapshotService) {
795
+ await this.initSnapshotService();
775
796
  }
776
- if (!this.calibrationService) {
777
- this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Calibration service unavailable');
797
+ if (!this.snapshotService) {
798
+ this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Snapshot service unavailable');
778
799
  return;
779
800
  }
780
- if (this.calibrationService.isCalibrating()) {
801
+ if (this.snapshotService.isCalibrating()) {
781
802
  console.log(`Inverter ${this.applianceId}: calibration already active — ack idempotently`);
782
803
  this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Accepted);
783
804
  return;
@@ -789,11 +810,7 @@ class SunspecInverter extends BaseSunspecDevice {
789
810
  return;
790
811
  }
791
812
  try {
792
- await this.calibrationService.startCalibration({
793
- deviceType: 'inverter',
794
- unitId: this.unitId,
795
- inverterControls: controls,
796
- });
813
+ await this.snapshotService.startCalibration(controls);
797
814
  }
798
815
  catch (error) {
799
816
  this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, `Failed to persist snapshot: ${error}`);
@@ -807,30 +824,23 @@ class SunspecInverter extends BaseSunspecDevice {
807
824
  return;
808
825
  }
809
826
  console.log(`Inverter ${this.applianceId}: handling StopCalibrationV1`);
810
- const snapshot = await this.calibrationService?.stopCalibration();
811
- if (!snapshot) {
812
- // No active calibrationtreat as a no-op success.
813
- this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Accepted);
814
- return;
815
- }
816
- try {
817
- await this.restoreFromCalibrationSnapshot(snapshot, 'stop');
818
- }
819
- catch (error) {
820
- this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, `Failed to restore registers: ${error}`);
821
- return;
822
- }
827
+ // SnapshotService.stopCalibration fires `restoreInverterSnapshot` internally with
828
+ // reason="stop" before returning. Any restore failure is logged by the callback
829
+ // (the persisted snapshot is already gone by then see library notes) so we
830
+ // always ack Accepted from here.
831
+ await this.snapshotService?.stopCalibration();
823
832
  this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Accepted);
824
833
  }
825
- async restoreFromCalibrationSnapshot(snapshot, reason) {
826
- if (snapshot.deviceType !== 'inverter' || !snapshot.inverterControls) {
827
- return;
828
- }
829
- const source = snapshot.inverterControls;
834
+ /**
835
+ * `onRestore` callback for the inverter's {@link SnapshotService}. Writes only the
836
+ * subset of writable inverter fields that other commands actually touched during the
837
+ * calibration. Catches and logs failures rather than throwing — the snapshot has
838
+ * already been removed from storage by the time this runs, so a throw is unrecoverable
839
+ * and is best logged for operator action.
840
+ */
841
+ async restoreInverterSnapshot(snapshot, reason) {
842
+ const source = snapshot.payload;
830
843
  const partial = {};
831
- // Whitelist of inverter fields that writeInverterControls actually writes (Model 123).
832
- // Iterating a typed const list keeps the projection type-safe and bounds the rollback
833
- // to fields the SDK knows how to restore.
834
844
  const WRITABLE_INVERTER_FIELDS = ['Conn', 'WMaxLimPct', 'WMaxLim_Ena', 'OutPFSet', 'OutPFSet_Ena'];
835
845
  for (const field of WRITABLE_INVERTER_FIELDS) {
836
846
  if (snapshot.modifiedFields.includes(field) && source[field] !== undefined) {
@@ -842,9 +852,14 @@ class SunspecInverter extends BaseSunspecDevice {
842
852
  return;
843
853
  }
844
854
  console.log(`Inverter ${this.applianceId}: calibration ${reason} — restoring [${Object.keys(partial).join(', ')}]`);
845
- const ok = await this.sunspecClient.writeInverterControls(snapshot.unitId, partial);
846
- if (!ok) {
847
- throw new Error('writeInverterControls returned false');
855
+ try {
856
+ const ok = await this.sunspecClient.writeInverterControls(this.unitId, partial);
857
+ if (!ok) {
858
+ console.error(`Inverter ${this.applianceId}: calibration ${reason} — writeInverterControls returned false`);
859
+ }
860
+ }
861
+ catch (error) {
862
+ console.error(`Inverter ${this.applianceId}: calibration ${reason} — writeInverterControls threw: ${error}`);
848
863
  }
849
864
  }
850
865
  }
@@ -853,11 +868,126 @@ exports.SunspecInverter = SunspecInverter;
853
868
  * Sunspec Battery implementation
854
869
  */
855
870
  class SunspecBattery extends BaseSunspecDevice {
871
+ featureMode;
856
872
  capabilities;
857
- constructor(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId = 1, port = 502, baseAddress = 40000, capabilities = [], retryConfig, appliance, useTls) {
873
+ snapshotService;
874
+ calibrationDriver;
875
+ batteryCalibrator;
876
+ calibrationResultStore;
877
+ scheduleHandler;
878
+ /**
879
+ * Battery features that imply outbound control writes from the host action-taker.
880
+ * These are stripped from `appliance.battery.features` until the calibration result
881
+ * store says this battery has been successfully calibrated — see
882
+ * {@link computeAdvertisedFeatures}.
883
+ */
884
+ static CONTROLLABLE_FEATURES = [
885
+ enyo_battery_appliance_js_1.EnyoBatteryFeature.GridCharging,
886
+ enyo_battery_appliance_js_1.EnyoBatteryFeature.GridDischarging,
887
+ enyo_battery_appliance_js_1.EnyoBatteryFeature.ChargeLimitation,
888
+ enyo_battery_appliance_js_1.EnyoBatteryFeature.DischargeLimitation,
889
+ ];
890
+ constructor(energyApp, name, networkDevice, sunspecClient, applianceManager, featureMode, unitId = 1, port = 502, baseAddress = 40000, capabilities = [], retryConfig, appliance, useTls) {
858
891
  super(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId, port, baseAddress, retryConfig, appliance, useTls);
892
+ this.featureMode = featureMode;
859
893
  this.capabilities = capabilities;
860
894
  }
895
+ /**
896
+ * Wire the battery into `@enyo-energy/appliance-calibration`'s
897
+ * `BatteryCalibrator` test-charge flow. Call once after {@link connect}.
898
+ *
899
+ * Consumers own the `CalibrationResultStore` (shared across appliances so
900
+ * the persisted result map doesn't get clobbered) and the
901
+ * `CalibrationTrigger` that drives `runCalibration()` on whatever schedule
902
+ * makes sense for the host. Pass overrides for the calibrator's defaults
903
+ * (`testPowerW`, response thresholds, etc.) via `opts.config`.
904
+ */
905
+ configureCalibration(opts) {
906
+ if (this.featureMode.kind !== sunspec_interfaces_js_1.SunspecBatteryFeatureModeKind.CalibrationBased) {
907
+ throw new Error(`SunspecBattery.configureCalibration: featureMode is '${this.featureMode.kind}', ` +
908
+ `but calibration is only valid in '${sunspec_interfaces_js_1.SunspecBatteryFeatureModeKind.CalibrationBased}' mode. ` +
909
+ `Pass { kind: SunspecBatteryFeatureModeKind.CalibrationBased } to the constructor.`);
910
+ }
911
+ if (this.batteryCalibrator) {
912
+ return this.batteryCalibrator;
913
+ }
914
+ if (!this.applianceId) {
915
+ throw new Error("SunspecBattery.configureCalibration: connect() must complete first (applianceId required)");
916
+ }
917
+ if (!this.snapshotService) {
918
+ throw new Error("SunspecBattery.configureCalibration: snapshot service not initialized — call connect() first");
919
+ }
920
+ const driver = new sunspec_battery_calibration_driver_js_1.SunspecBatteryCalibrationDriver(this.sunspecClient, this.unitId, this.energyApp.useDataBus());
921
+ this.calibrationDriver = driver;
922
+ this.calibrationResultStore = opts.resultStore;
923
+ this.batteryCalibrator = new sunspec_battery_feature_calibrator_js_1.SunspecBatteryFeatureCalibrator({
924
+ applianceId: this.applianceId,
925
+ driver,
926
+ sunspecClient: this.sunspecClient,
927
+ unitId: this.unitId,
928
+ readBatteryData: () => this.sunspecClient.readBatteryData(this.unitId),
929
+ snapshotService: this.snapshotService,
930
+ resultStore: opts.resultStore,
931
+ config: opts.config,
932
+ });
933
+ return this.batteryCalibrator;
934
+ }
935
+ /**
936
+ * Detect which battery features the device's registers expose. This is the
937
+ * raw, register-only view; the {@link featureMode} configured at
938
+ * construction decides what is actually published — see
939
+ * {@link resolveAdvertisedFeatures}.
940
+ */
941
+ detectFromRegisters(batteryData) {
942
+ const features = [];
943
+ if (batteryData?.chaGriSet !== undefined) {
944
+ features.push(enyo_battery_appliance_js_1.EnyoBatteryFeature.GridCharging);
945
+ }
946
+ if (batteryData?.wChaMax !== undefined) {
947
+ features.push(enyo_battery_appliance_js_1.EnyoBatteryFeature.ChargeLimitation);
948
+ }
949
+ return features;
950
+ }
951
+ /**
952
+ * Resolve `appliance.battery.features` for the configured mode. Called on
953
+ * every connect() and readData() cycle so that, in calibration-based mode,
954
+ * the controllable features appear as soon as the calibrator flips
955
+ * `isCalibrated` (no synchronous push).
956
+ */
957
+ resolveAdvertisedFeatures(batteryData) {
958
+ const allow = this.featureMode.allowedFeatures;
959
+ const intersect = (xs) => allow ? xs.filter(f => allow.includes(f)) : xs;
960
+ switch (this.featureMode.kind) {
961
+ case sunspec_interfaces_js_1.SunspecBatteryFeatureModeKind.Disabled:
962
+ return allow ? [...allow] : [];
963
+ case sunspec_interfaces_js_1.SunspecBatteryFeatureModeKind.RegisterBased:
964
+ return intersect(this.detectFromRegisters(batteryData));
965
+ case sunspec_interfaces_js_1.SunspecBatteryFeatureModeKind.CalibrationBased: {
966
+ const detected = intersect(this.detectFromRegisters(batteryData));
967
+ if (!this.applianceId || !this.calibrationResultStore) {
968
+ return detected.filter(f => !SunspecBattery.CONTROLLABLE_FEATURES.includes(f));
969
+ }
970
+ const result = this.calibrationResultStore.getResult(this.applianceId);
971
+ if (!result || result.state !== 'calibrated') {
972
+ return detected.filter(f => !SunspecBattery.CONTROLLABLE_FEATURES.includes(f));
973
+ }
974
+ // Per-feature gating: only publish controllable features whose
975
+ // probe passed during the last calibration. See
976
+ // `decodeFeatureResults` and `SunspecBatteryFeatureCalibrator`.
977
+ const passed = (0, sunspec_battery_feature_calibrator_js_1.decodeFeatureResults)(result.notes);
978
+ return detected.filter(f => !SunspecBattery.CONTROLLABLE_FEATURES.includes(f) || passed.has(f));
979
+ }
980
+ }
981
+ }
982
+ /**
983
+ * Returns the per-battery `BatteryCalibrator` created by
984
+ * {@link configureCalibration}, or `undefined` if calibration was never
985
+ * configured. Consumers register the returned instance with their own
986
+ * `CalibrationTrigger`.
987
+ */
988
+ getBatteryCalibrator() {
989
+ return this.batteryCalibrator;
990
+ }
861
991
  /**
862
992
  * Connect to the battery and create/update the appliance
863
993
  */
@@ -874,13 +1004,7 @@ class SunspecBattery extends BaseSunspecDevice {
874
1004
  const commonData = await this.sunspecClient.readCommonBlock(this.unitId);
875
1005
  const batteryData = await this.sunspecClient.readBatteryData(this.unitId);
876
1006
  const storageMode = this.determineStorageMode(batteryData);
877
- const features = [];
878
- if (batteryData?.chaGriSet !== undefined) {
879
- features.push(enyo_battery_appliance_js_1.EnyoBatteryFeature.GridCharging);
880
- }
881
- if (batteryData?.wChaMax !== undefined) {
882
- features.push(enyo_battery_appliance_js_1.EnyoBatteryFeature.ChargeLimitation);
883
- }
1007
+ const features = this.resolveAdvertisedFeatures(batteryData);
884
1008
  const activeChargeLimitW = this.computeActiveChargeLimitW(batteryData);
885
1009
  const activeDischargeLimitW = this.computeActiveDischargeLimitW(batteryData);
886
1010
  // Create or update appliance (skip if an existing appliance was provided)
@@ -947,6 +1071,11 @@ class SunspecBattery extends BaseSunspecDevice {
947
1071
  }
948
1072
  async disconnect() {
949
1073
  this.stopDataBusListening();
1074
+ if (this.calibrationDriver) {
1075
+ this.calibrationDriver.stop();
1076
+ this.calibrationDriver = undefined;
1077
+ this.batteryCalibrator = undefined;
1078
+ }
950
1079
  if (this.applianceId) {
951
1080
  try {
952
1081
  await this.applianceManager.updateApplianceState(this.applianceId, enyo_appliance_js_1.EnyoApplianceConnectionType.Connector, enyo_appliance_js_1.EnyoApplianceStateEnum.Offline);
@@ -1015,6 +1144,11 @@ class SunspecBattery extends BaseSunspecDevice {
1015
1144
  else if (!advancedBatteryModel) {
1016
1145
  batteryPowerW = mpptBatteryPowerW;
1017
1146
  }
1147
+ // Feed the calibration driver's synchronous power cache. No-op when calibration
1148
+ // was never configured.
1149
+ if (batteryPowerW !== undefined && this.calibrationDriver) {
1150
+ this.calibrationDriver.updateBatteryPowerCache(batteryPowerW);
1151
+ }
1018
1152
  const batteryMessage = {
1019
1153
  id: (0, node_crypto_1.randomUUID)(),
1020
1154
  message: enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.BatteryValuesUpdateV1,
@@ -1038,6 +1172,9 @@ class SunspecBattery extends BaseSunspecDevice {
1038
1172
  if (this.applianceId) {
1039
1173
  const appliance = await this.applianceManager.findApplianceById(this.applianceId);
1040
1174
  const storageMode = this.determineStorageMode(batteryData);
1175
+ // Republish features each cycle so the controllable set appears as soon
1176
+ // as the calibrator flips isCalibrated — see resolveAdvertisedFeatures.
1177
+ const features = this.resolveAdvertisedFeatures(batteryData);
1041
1178
  await this.applianceManager.updateAppliance(this.applianceId, {
1042
1179
  battery: {
1043
1180
  ...appliance?.battery,
@@ -1046,6 +1183,7 @@ class SunspecBattery extends BaseSunspecDevice {
1046
1183
  maxDischargePowerW: batteryData && batteryData.wChaMax !== undefined && batteryData.outWRte !== undefined ? batteryData.wChaMax * batteryData.outWRte : undefined,
1047
1184
  activeChargeLimitW: this.computeActiveChargeLimitW(batteryData),
1048
1185
  activeDischargeLimitW: this.computeActiveDischargeLimitW(batteryData),
1186
+ features,
1049
1187
  gridChargingEnabled: batteryData?.chaGriSet === 1
1050
1188
  }
1051
1189
  });
@@ -1291,17 +1429,27 @@ class SunspecBattery extends BaseSunspecDevice {
1291
1429
  if (this.dataBusListenerId) {
1292
1430
  return;
1293
1431
  }
1294
- // Fire and forget — calibration service hydration must not block listening setup.
1295
- void this.initCalibrationService('battery');
1432
+ // Fire and forget — snapshot service hydration must not block listener setup.
1433
+ void this.initSnapshotService();
1296
1434
  this.dataBus = this.energyApp.useDataBus();
1297
1435
  this.dataBusListenerId = this.dataBus.listenForMessages([
1298
- enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.StartStorageGridChargeV1,
1299
- enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.StopStorageGridChargeV1,
1300
- enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.SetStorageDischargeLimitV1,
1301
- enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.SetStorageChargeLimitV1,
1436
+ enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.SetStorageScheduleV1,
1302
1437
  enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.StartCalibrationV1,
1303
1438
  enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.StopCalibrationV1,
1304
1439
  ], (entry) => this.handleStorageCommand(entry));
1440
+ if (!this.applianceId) {
1441
+ throw new Error("SunspecBattery.startDataBusListening: applianceId required — call connect() first.");
1442
+ }
1443
+ this.scheduleHandler = new sunspec_battery_schedule_handler_js_1.SunspecBatteryScheduleHandler({
1444
+ dataBus: this.dataBus,
1445
+ interval: this.energyApp.useInterval(),
1446
+ storage: this.energyApp.useStorage(),
1447
+ applianceId: this.applianceId,
1448
+ sunspecClient: this.sunspecClient,
1449
+ unitId: this.unitId,
1450
+ getSnapshotService: () => this.snapshotService,
1451
+ onErrorCallback: (err) => console.warn(`Battery ${this.applianceId}: schedule handler rejected input: ${err.message}`),
1452
+ });
1305
1453
  console.log(`Battery ${this.applianceId}: started data bus listening (listener ${this.dataBusListenerId})`);
1306
1454
  }
1307
1455
  /**
@@ -1314,6 +1462,7 @@ class SunspecBattery extends BaseSunspecDevice {
1314
1462
  }
1315
1463
  this.dataBusListenerId = undefined;
1316
1464
  this.dataBus = undefined;
1465
+ this.scheduleHandler?.dispose();
1317
1466
  }
1318
1467
  handleStorageCommand(entry) {
1319
1468
  if (entry.applianceId !== this.applianceId) {
@@ -1322,17 +1471,8 @@ class SunspecBattery extends BaseSunspecDevice {
1322
1471
  void (async () => {
1323
1472
  try {
1324
1473
  switch (entry.message) {
1325
- case enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.StartStorageGridChargeV1:
1326
- await this.handleStartGridCharge(entry);
1327
- break;
1328
- case enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.StopStorageGridChargeV1:
1329
- await this.handleStopGridCharge(entry);
1330
- break;
1331
- case enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.SetStorageDischargeLimitV1:
1332
- await this.handleSetDischargeLimit(entry);
1333
- break;
1334
- case enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.SetStorageChargeLimitV1:
1335
- await this.handleSetChargeLimit(entry);
1474
+ case enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.SetStorageScheduleV1:
1475
+ this.handleSetStorageScheduleAck(entry);
1336
1476
  break;
1337
1477
  case enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.StartCalibrationV1:
1338
1478
  await this.handleStartCalibration(entry);
@@ -1347,132 +1487,51 @@ class SunspecBattery extends BaseSunspecDevice {
1347
1487
  }
1348
1488
  })();
1349
1489
  }
1350
- async handleStartGridCharge(msg) {
1490
+ /**
1491
+ * Acknowledge a SetStorageScheduleV1 message. The actual schedule application
1492
+ * is owned by `this.scheduleHandler` (subscribed independently via its own
1493
+ * data-bus listener registered in its constructor). The ack reports "received
1494
+ * & queued" rather than "applied" — schedule entries play out over time, and
1495
+ * per-entry write failures land in `console.error` via the handler's
1496
+ * onChange wrapper.
1497
+ */
1498
+ handleSetStorageScheduleAck(msg) {
1351
1499
  if (!this.isConnected() || !this.applianceId) {
1352
1500
  this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, "Not connected");
1353
1501
  return;
1354
1502
  }
1355
- console.log(`Battery ${this.applianceId}: handling StartStorageGridChargeV1 (powerLimitW=${msg.data.powerLimitW})`);
1356
- // Read current state for logging and rollback
1357
- const controls = await this.getBatteryControls();
1358
- console.log(`Battery ${this.applianceId}: current state - chaGriSet=${controls?.chaGriSet}, wChaMax=${controls?.wChaMax}, storCtlMod=${controls?.storCtlMod}`);
1359
- const originalChaGriSet = controls?.chaGriSet;
1360
- const originalWChaMax = controls?.wChaMax;
1361
- // Step 1: Enable grid charging (Register 17: chaGriSet=GRID)
1362
- const step1 = await this.enableGridCharging(true);
1363
- if (!step1) {
1364
- this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to enable grid charging');
1365
- return;
1366
- }
1367
- // Step 2: Set charging power (Register 2: wChaMax)
1368
- const step2 = await this.setChargingPower(msg.data.powerLimitW);
1369
- if (!step2) {
1370
- // Rollback step 1
1371
- await this.writeBatteryControls({ chaGriSet: originalChaGriSet });
1372
- this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to set charging power');
1373
- return;
1374
- }
1375
- // Step 3: Set storage mode to CHARGE (Register 5: storCtlMod=0x0001)
1376
- const step3 = await this.setStorageMode(sunspec_interfaces_js_1.SunspecStorageMode.CHARGE);
1377
- if (!step3) {
1378
- // Rollback steps 1+2
1379
- await this.writeBatteryControls({ chaGriSet: originalChaGriSet, wChaMax: originalWChaMax });
1380
- this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to set storage mode to charge');
1381
- return;
1382
- }
1383
- await this.calibrationService?.recordModification(['chaGriSet', 'wChaMax', 'storCtlMod']);
1384
- this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Accepted);
1385
- }
1386
- async handleStopGridCharge(msg) {
1387
- if (!this.isConnected() || !this.applianceId) {
1388
- this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.NotSupported);
1389
- return;
1390
- }
1391
- console.log(`Battery ${this.applianceId}: handling StopStorageGridChargeV1`);
1392
- // Read current state for logging and rollback
1393
- const controls = await this.getBatteryControls();
1394
- console.log(`Battery ${this.applianceId}: current state - chaGriSet=${controls?.chaGriSet}, storCtlMod=${controls?.storCtlMod}`);
1395
- const originalChaGriSet = controls?.chaGriSet;
1396
- // Step 1: Disable grid charging (Register 17: chaGriSet=PV)
1397
- const step1 = await this.enableGridCharging(false);
1398
- if (!step1) {
1399
- this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to disable grid charging');
1400
- return;
1401
- }
1402
- // Step 2: Set storage mode to AUTO (Register 5: storCtlMod=0x0003)
1403
- const step2 = await this.setStorageMode(sunspec_interfaces_js_1.SunspecStorageMode.AUTO);
1404
- if (!step2) {
1405
- // Rollback step 1
1406
- await this.writeBatteryControls({ chaGriSet: originalChaGriSet });
1407
- this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to set storage mode to auto');
1408
- return;
1409
- }
1410
- await this.calibrationService?.recordModification(['chaGriSet', 'storCtlMod']);
1411
- this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Accepted);
1412
- }
1413
- async handleSetDischargeLimit(msg) {
1414
- if (!this.isConnected() || !this.applianceId) {
1415
- this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.NotSupported);
1416
- return;
1417
- }
1418
- console.log(`Battery ${this.applianceId}: handling SetStorageDischargeLimitV1 (dischargeLimitW=${msg.data.dischargeLimitW})`);
1419
- // Read current state to get wChaMax for percentage conversion
1420
- const controls = await this.getBatteryControls();
1421
- console.log(`Battery ${this.applianceId}: current state - outWRte=${controls?.outWRte}, wChaMax=${controls?.wChaMax}`);
1422
- if (!controls?.wChaMax) {
1423
- this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to read wChaMax for discharge limit conversion');
1424
- return;
1425
- }
1426
- // Convert watts to percentage of WDisChaMax (using wChaMax), clamped to 0-100%
1427
- const dischargeLimitPercent = Math.min(100, Math.max(0, (msg.data.dischargeLimitW / controls.wChaMax) * 100));
1428
- console.log(`Battery ${this.applianceId}: calculated discharge limit: ${dischargeLimitPercent.toFixed(1)}% (${msg.data.dischargeLimitW}W / ${controls.wChaMax}W)`);
1429
- // Set discharge limit (Register 12: outWRte)
1430
- const success = await this.writeBatteryControls({ outWRte: dischargeLimitPercent });
1431
- if (!success) {
1432
- this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to set discharge limit');
1433
- return;
1434
- }
1435
- await this.calibrationService?.recordModification(['outWRte']);
1436
1503
  this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Accepted);
1437
1504
  }
1438
- async handleSetChargeLimit(msg) {
1439
- if (!this.isConnected() || !this.applianceId) {
1440
- this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.NotSupported);
1441
- return;
1442
- }
1443
- console.log(`Battery ${this.applianceId}: handling SetStorageChargeLimitV1 (chargeLimitW=${msg.data.chargeLimitW})`);
1444
- // Read current state to get wChaMax for percentage conversion
1445
- const controls = await this.getBatteryControls();
1446
- console.log(`Battery ${this.applianceId}: current state - inWRte=${controls?.inWRte}, wChaMax=${controls?.wChaMax}`);
1447
- if (!controls?.wChaMax) {
1448
- this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to read wChaMax for charge limit conversion');
1505
+ /**
1506
+ * Lazily construct the per-appliance {@link SnapshotService}. Reloads any
1507
+ * persisted snapshot from storage so an in-flight calibration survives
1508
+ * process restarts. Idempotent.
1509
+ */
1510
+ async initSnapshotService() {
1511
+ if (this.snapshotService) {
1449
1512
  return;
1450
1513
  }
1451
- // Convert watts to percentage of WChaMax, clamped to 0-100%
1452
- const chargeLimitPercent = Math.min(100, Math.max(0, (msg.data.chargeLimitW / controls.wChaMax) * 100));
1453
- console.log(`Battery ${this.applianceId}: calculated charge limit: ${chargeLimitPercent.toFixed(1)}% (${msg.data.chargeLimitW}W / ${controls.wChaMax}W)`);
1454
- // Set charge limit (Register 13: inWRte)
1455
- const success = await this.writeBatteryControls({ inWRte: chargeLimitPercent });
1456
- if (!success) {
1457
- this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to set charge limit');
1514
+ const service = this.buildSnapshotService((snapshot, reason) => this.restoreBatterySnapshot(snapshot, reason));
1515
+ if (!service) {
1458
1516
  return;
1459
1517
  }
1460
- await this.calibrationService?.recordModification(['inWRte']);
1461
- this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Accepted);
1518
+ this.snapshotService = service;
1519
+ await this.snapshotService.initialize();
1520
+ console.log(`Battery ${this.applianceId}: snapshot service initialized`);
1462
1521
  }
1463
1522
  async handleStartCalibration(msg) {
1464
1523
  if (!this.isConnected() || !this.applianceId) {
1465
1524
  this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Not connected');
1466
1525
  return;
1467
1526
  }
1468
- if (!this.calibrationService) {
1469
- await this.initCalibrationService('battery');
1527
+ if (!this.snapshotService) {
1528
+ await this.initSnapshotService();
1470
1529
  }
1471
- if (!this.calibrationService) {
1472
- this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Calibration service unavailable');
1530
+ if (!this.snapshotService) {
1531
+ this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Snapshot service unavailable');
1473
1532
  return;
1474
1533
  }
1475
- if (this.calibrationService.isCalibrating()) {
1534
+ if (this.snapshotService.isCalibrating()) {
1476
1535
  console.log(`Battery ${this.applianceId}: calibration already active — ack idempotently`);
1477
1536
  this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Accepted);
1478
1537
  return;
@@ -1484,11 +1543,7 @@ class SunspecBattery extends BaseSunspecDevice {
1484
1543
  return;
1485
1544
  }
1486
1545
  try {
1487
- await this.calibrationService.startCalibration({
1488
- deviceType: 'battery',
1489
- unitId: this.unitId,
1490
- batteryControls: controls,
1491
- });
1546
+ await this.snapshotService.startCalibration(controls);
1492
1547
  }
1493
1548
  catch (error) {
1494
1549
  this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, `Failed to persist snapshot: ${error}`);
@@ -1502,27 +1557,22 @@ class SunspecBattery extends BaseSunspecDevice {
1502
1557
  return;
1503
1558
  }
1504
1559
  console.log(`Battery ${this.applianceId}: handling StopCalibrationV1`);
1505
- const snapshot = await this.calibrationService?.stopCalibration();
1506
- if (!snapshot) {
1507
- this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Accepted);
1508
- return;
1509
- }
1510
- try {
1511
- await this.restoreFromCalibrationSnapshot(snapshot, 'stop');
1512
- }
1513
- catch (error) {
1514
- this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, `Failed to restore registers: ${error}`);
1515
- return;
1516
- }
1560
+ // SnapshotService.stopCalibration fires `restoreBatterySnapshot` internally
1561
+ // with reason="stop" before returning. Failures from the restore callback are
1562
+ // logged inside the callback (the persisted snapshot is already gone — see
1563
+ // library notes) so we always ack Accepted here.
1564
+ await this.snapshotService?.stopCalibration();
1517
1565
  this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Accepted);
1518
1566
  }
1519
- async restoreFromCalibrationSnapshot(snapshot, reason) {
1520
- if (snapshot.deviceType !== 'battery' || !snapshot.batteryControls) {
1521
- return;
1522
- }
1523
- const source = snapshot.batteryControls;
1567
+ /**
1568
+ * `onRestore` callback for the battery's {@link SnapshotService}. Writes only the
1569
+ * subset of writable battery fields touched during the calibration. Errors are
1570
+ * caught and logged — by the time this fires the snapshot is gone from storage,
1571
+ * so a throw would just propagate into the void.
1572
+ */
1573
+ async restoreBatterySnapshot(snapshot, reason) {
1574
+ const source = snapshot.payload;
1524
1575
  const partial = {};
1525
- // Whitelist of battery fields that writeBatteryControls actually writes (Model 124).
1526
1576
  const WRITABLE_BATTERY_FIELDS = ['storCtlMod', 'chaGriSet', 'wChaMax', 'inWRte', 'outWRte', 'minRsvPct'];
1527
1577
  for (const field of WRITABLE_BATTERY_FIELDS) {
1528
1578
  if (snapshot.modifiedFields.includes(field) && source[field] !== undefined) {
@@ -1534,9 +1584,14 @@ class SunspecBattery extends BaseSunspecDevice {
1534
1584
  return;
1535
1585
  }
1536
1586
  console.log(`Battery ${this.applianceId}: calibration ${reason} — restoring [${Object.keys(partial).join(', ')}]`);
1537
- const ok = await this.sunspecClient.writeBatteryControls(snapshot.unitId, partial);
1538
- if (!ok) {
1539
- throw new Error('writeBatteryControls returned false');
1587
+ try {
1588
+ const ok = await this.sunspecClient.writeBatteryControls(this.unitId, partial);
1589
+ if (!ok) {
1590
+ console.error(`Battery ${this.applianceId}: calibration ${reason} — writeBatteryControls returned false`);
1591
+ }
1592
+ }
1593
+ catch (error) {
1594
+ console.error(`Battery ${this.applianceId}: calibration ${reason} — writeBatteryControls threw: ${error}`);
1540
1595
  }
1541
1596
  }
1542
1597
  }