@enyo-energy/sunspec-sdk 0.0.70 → 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 (38) hide show
  1. package/README.md +302 -0
  2. package/dist/calibration-snapshot-service.d.ts +67 -0
  3. package/dist/calibration-snapshot-service.js +160 -0
  4. package/dist/cjs/calibration-snapshot-service.cjs +164 -0
  5. package/dist/cjs/calibration-snapshot-service.d.cts +67 -0
  6. package/dist/cjs/index.cjs +30 -1
  7. package/dist/cjs/index.d.cts +6 -0
  8. package/dist/cjs/sunspec-battery-calibration-driver.cjs +158 -0
  9. package/dist/cjs/sunspec-battery-calibration-driver.d.cts +63 -0
  10. package/dist/cjs/sunspec-battery-feature-calibrator.cjs +350 -0
  11. package/dist/cjs/sunspec-battery-feature-calibrator.d.cts +89 -0
  12. package/dist/cjs/sunspec-battery-schedule-handler.cjs +92 -0
  13. package/dist/cjs/sunspec-battery-schedule-handler.d.cts +67 -0
  14. package/dist/cjs/sunspec-calibration-storage.cjs +47 -0
  15. package/dist/cjs/sunspec-calibration-storage.d.cts +24 -0
  16. package/dist/cjs/sunspec-devices.cjs +407 -104
  17. package/dist/cjs/sunspec-devices.d.cts +112 -6
  18. package/dist/cjs/sunspec-interfaces.cjs +42 -1
  19. package/dist/cjs/sunspec-interfaces.d.cts +66 -0
  20. package/dist/cjs/version.cjs +1 -1
  21. package/dist/cjs/version.d.cts +1 -1
  22. package/dist/index.d.ts +6 -0
  23. package/dist/index.js +12 -0
  24. package/dist/sunspec-battery-calibration-driver.d.ts +63 -0
  25. package/dist/sunspec-battery-calibration-driver.js +154 -0
  26. package/dist/sunspec-battery-feature-calibrator.d.ts +89 -0
  27. package/dist/sunspec-battery-feature-calibrator.js +345 -0
  28. package/dist/sunspec-battery-schedule-handler.d.ts +67 -0
  29. package/dist/sunspec-battery-schedule-handler.js +88 -0
  30. package/dist/sunspec-calibration-storage.d.ts +24 -0
  31. package/dist/sunspec-calibration-storage.js +42 -0
  32. package/dist/sunspec-devices.d.ts +112 -6
  33. package/dist/sunspec-devices.js +408 -105
  34. package/dist/sunspec-interfaces.d.ts +66 -0
  35. package/dist/sunspec-interfaces.js +41 -0
  36. package/dist/version.d.ts +1 -1
  37. package/dist/version.js +1 -1
  38. package/package.json +7 -3
@@ -6,6 +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 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");
9
14
  const enyo_source_enum_js_1 = require("@enyo-energy/energy-app-sdk/dist/types/enyo-source.enum.js");
10
15
  const enyo_meter_appliance_js_1 = require("@enyo-energy/energy-app-sdk/dist/types/enyo-meter-appliance.js");
11
16
  const enyo_battery_appliance_js_1 = require("@enyo-energy/energy-app-sdk/dist/types/enyo-battery-appliance.js");
@@ -99,6 +104,13 @@ class BaseSunspecDevice {
99
104
  dataBus;
100
105
  retryManager;
101
106
  consecutiveReconnectFailures = 0;
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-";
102
114
  constructor(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId = 1, port = 502, baseAddress = 40000, retryConfig, appliance, useTls) {
103
115
  this.energyApp = energyApp;
104
116
  this.name = name;
@@ -252,6 +264,20 @@ class BaseSunspecDevice {
252
264
  console.log(`${this.constructor.name} ${this.applianceId}: sending ${answer} for ${acknowledgeMessage} (messageId=${messageId}${rejectionReason ? `, reason=${rejectionReason}` : ''})`);
253
265
  this.dataBus.sendMessage([ackMessage]);
254
266
  }
267
+ /**
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.
271
+ */
272
+ buildSnapshotService(onRestore) {
273
+ if (!this.applianceId) {
274
+ return undefined;
275
+ }
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
+ });
280
+ }
255
281
  }
256
282
  exports.BaseSunspecDevice = BaseSunspecDevice;
257
283
  /**
@@ -263,6 +289,7 @@ class SunspecInverter extends BaseSunspecDevice {
263
289
  static CONNECTION_FAULT_THRESHOLD = 3;
264
290
  storage;
265
291
  errorState = { activeCodes: [], lastStatus: 'healthy' };
292
+ snapshotService;
266
293
  constructor(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId = 1, port = 502, baseAddress = 40000, capabilities = [], retryConfig, appliance, useTls) {
267
294
  super(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId, port, baseAddress, retryConfig, appliance, useTls);
268
295
  this.capabilities = capabilities;
@@ -675,8 +702,16 @@ class SunspecInverter extends BaseSunspecDevice {
675
702
  if (this.dataBusListenerId) {
676
703
  return;
677
704
  }
705
+ // Fire and forget — snapshot service hydration must not block listener setup.
706
+ // Listener registration before await ensures we don't miss messages that arrive
707
+ // during the initial load.
708
+ void this.initSnapshotService();
678
709
  this.dataBus = this.energyApp.useDataBus();
679
- this.dataBusListenerId = this.dataBus.listenForMessages([enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.SetInverterFeedInLimitV1], (entry) => this.handleInverterCommand(entry));
710
+ this.dataBusListenerId = this.dataBus.listenForMessages([
711
+ enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.SetInverterFeedInLimitV1,
712
+ enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.StartCalibrationV1,
713
+ enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.StopCalibrationV1,
714
+ ], (entry) => this.handleInverterCommand(entry));
680
715
  console.log(`Inverter ${this.applianceId}: started data bus listening (listener ${this.dataBusListenerId})`);
681
716
  }
682
717
  /**
@@ -696,8 +731,16 @@ class SunspecInverter extends BaseSunspecDevice {
696
731
  }
697
732
  void (async () => {
698
733
  try {
699
- if (entry.message === enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.SetInverterFeedInLimitV1) {
700
- await this.handleSetFeedInLimit(entry);
734
+ switch (entry.message) {
735
+ case enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.SetInverterFeedInLimitV1:
736
+ await this.handleSetFeedInLimit(entry);
737
+ break;
738
+ case enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.StartCalibrationV1:
739
+ await this.handleStartCalibration(entry);
740
+ break;
741
+ case enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.StopCalibrationV1:
742
+ await this.handleStopCalibration(entry);
743
+ break;
701
744
  }
702
745
  }
703
746
  catch (error) {
@@ -721,19 +764,230 @@ class SunspecInverter extends BaseSunspecDevice {
721
764
  this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to set feed-in limit');
722
765
  return;
723
766
  }
767
+ // setFeedInLimit writes WMaxLimPct + WMaxLim_Ena (see SunspecModbusClient.setFeedInLimit).
768
+ // Record both so an active calibration can roll them back on stop.
769
+ await this.snapshotService?.recordModification(['WMaxLimPct', 'WMaxLim_Ena']);
770
+ this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Accepted);
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
+ }
789
+ async handleStartCalibration(msg) {
790
+ if (!this.isConnected() || !this.applianceId) {
791
+ this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Not connected');
792
+ return;
793
+ }
794
+ if (!this.snapshotService) {
795
+ await this.initSnapshotService();
796
+ }
797
+ if (!this.snapshotService) {
798
+ this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Snapshot service unavailable');
799
+ return;
800
+ }
801
+ if (this.snapshotService.isCalibrating()) {
802
+ console.log(`Inverter ${this.applianceId}: calibration already active — ack idempotently`);
803
+ this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Accepted);
804
+ return;
805
+ }
806
+ console.log(`Inverter ${this.applianceId}: handling StartCalibrationV1`);
807
+ const controls = await this.sunspecClient.readInverterControls(this.unitId);
808
+ if (!controls) {
809
+ this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to read inverter controls for snapshot');
810
+ return;
811
+ }
812
+ try {
813
+ await this.snapshotService.startCalibration(controls);
814
+ }
815
+ catch (error) {
816
+ this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, `Failed to persist snapshot: ${error}`);
817
+ return;
818
+ }
819
+ this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Accepted);
820
+ }
821
+ async handleStopCalibration(msg) {
822
+ if (!this.applianceId) {
823
+ this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Not initialized');
824
+ return;
825
+ }
826
+ console.log(`Inverter ${this.applianceId}: handling StopCalibrationV1`);
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();
724
832
  this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Accepted);
725
833
  }
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;
843
+ const partial = {};
844
+ const WRITABLE_INVERTER_FIELDS = ['Conn', 'WMaxLimPct', 'WMaxLim_Ena', 'OutPFSet', 'OutPFSet_Ena'];
845
+ for (const field of WRITABLE_INVERTER_FIELDS) {
846
+ if (snapshot.modifiedFields.includes(field) && source[field] !== undefined) {
847
+ partial[field] = source[field];
848
+ }
849
+ }
850
+ if (Object.keys(partial).length === 0) {
851
+ console.log(`Inverter ${this.applianceId}: calibration ${reason} — no modified fields to restore`);
852
+ return;
853
+ }
854
+ console.log(`Inverter ${this.applianceId}: calibration ${reason} — restoring [${Object.keys(partial).join(', ')}]`);
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}`);
863
+ }
864
+ }
726
865
  }
727
866
  exports.SunspecInverter = SunspecInverter;
728
867
  /**
729
868
  * Sunspec Battery implementation
730
869
  */
731
870
  class SunspecBattery extends BaseSunspecDevice {
871
+ featureMode;
732
872
  capabilities;
733
- 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) {
734
891
  super(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId, port, baseAddress, retryConfig, appliance, useTls);
892
+ this.featureMode = featureMode;
735
893
  this.capabilities = capabilities;
736
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
+ }
737
991
  /**
738
992
  * Connect to the battery and create/update the appliance
739
993
  */
@@ -750,13 +1004,7 @@ class SunspecBattery extends BaseSunspecDevice {
750
1004
  const commonData = await this.sunspecClient.readCommonBlock(this.unitId);
751
1005
  const batteryData = await this.sunspecClient.readBatteryData(this.unitId);
752
1006
  const storageMode = this.determineStorageMode(batteryData);
753
- const features = [];
754
- if (batteryData?.chaGriSet !== undefined) {
755
- features.push(enyo_battery_appliance_js_1.EnyoBatteryFeature.GridCharging);
756
- }
757
- if (batteryData?.wChaMax !== undefined) {
758
- features.push(enyo_battery_appliance_js_1.EnyoBatteryFeature.ChargeLimitation);
759
- }
1007
+ const features = this.resolveAdvertisedFeatures(batteryData);
760
1008
  const activeChargeLimitW = this.computeActiveChargeLimitW(batteryData);
761
1009
  const activeDischargeLimitW = this.computeActiveDischargeLimitW(batteryData);
762
1010
  // Create or update appliance (skip if an existing appliance was provided)
@@ -823,6 +1071,11 @@ class SunspecBattery extends BaseSunspecDevice {
823
1071
  }
824
1072
  async disconnect() {
825
1073
  this.stopDataBusListening();
1074
+ if (this.calibrationDriver) {
1075
+ this.calibrationDriver.stop();
1076
+ this.calibrationDriver = undefined;
1077
+ this.batteryCalibrator = undefined;
1078
+ }
826
1079
  if (this.applianceId) {
827
1080
  try {
828
1081
  await this.applianceManager.updateApplianceState(this.applianceId, enyo_appliance_js_1.EnyoApplianceConnectionType.Connector, enyo_appliance_js_1.EnyoApplianceStateEnum.Offline);
@@ -891,6 +1144,11 @@ class SunspecBattery extends BaseSunspecDevice {
891
1144
  else if (!advancedBatteryModel) {
892
1145
  batteryPowerW = mpptBatteryPowerW;
893
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
+ }
894
1152
  const batteryMessage = {
895
1153
  id: (0, node_crypto_1.randomUUID)(),
896
1154
  message: enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.BatteryValuesUpdateV1,
@@ -914,6 +1172,9 @@ class SunspecBattery extends BaseSunspecDevice {
914
1172
  if (this.applianceId) {
915
1173
  const appliance = await this.applianceManager.findApplianceById(this.applianceId);
916
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);
917
1178
  await this.applianceManager.updateAppliance(this.applianceId, {
918
1179
  battery: {
919
1180
  ...appliance?.battery,
@@ -922,6 +1183,7 @@ class SunspecBattery extends BaseSunspecDevice {
922
1183
  maxDischargePowerW: batteryData && batteryData.wChaMax !== undefined && batteryData.outWRte !== undefined ? batteryData.wChaMax * batteryData.outWRte : undefined,
923
1184
  activeChargeLimitW: this.computeActiveChargeLimitW(batteryData),
924
1185
  activeDischargeLimitW: this.computeActiveDischargeLimitW(batteryData),
1186
+ features,
925
1187
  gridChargingEnabled: batteryData?.chaGriSet === 1
926
1188
  }
927
1189
  });
@@ -1167,13 +1429,27 @@ class SunspecBattery extends BaseSunspecDevice {
1167
1429
  if (this.dataBusListenerId) {
1168
1430
  return;
1169
1431
  }
1432
+ // Fire and forget — snapshot service hydration must not block listener setup.
1433
+ void this.initSnapshotService();
1170
1434
  this.dataBus = this.energyApp.useDataBus();
1171
1435
  this.dataBusListenerId = this.dataBus.listenForMessages([
1172
- enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.StartStorageGridChargeV1,
1173
- enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.StopStorageGridChargeV1,
1174
- enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.SetStorageDischargeLimitV1,
1175
- enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.SetStorageChargeLimitV1
1436
+ enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.SetStorageScheduleV1,
1437
+ enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.StartCalibrationV1,
1438
+ enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.StopCalibrationV1,
1176
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
+ });
1177
1453
  console.log(`Battery ${this.applianceId}: started data bus listening (listener ${this.dataBusListenerId})`);
1178
1454
  }
1179
1455
  /**
@@ -1186,6 +1462,7 @@ class SunspecBattery extends BaseSunspecDevice {
1186
1462
  }
1187
1463
  this.dataBusListenerId = undefined;
1188
1464
  this.dataBus = undefined;
1465
+ this.scheduleHandler?.dispose();
1189
1466
  }
1190
1467
  handleStorageCommand(entry) {
1191
1468
  if (entry.applianceId !== this.applianceId) {
@@ -1194,17 +1471,14 @@ class SunspecBattery extends BaseSunspecDevice {
1194
1471
  void (async () => {
1195
1472
  try {
1196
1473
  switch (entry.message) {
1197
- case enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.StartStorageGridChargeV1:
1198
- await this.handleStartGridCharge(entry);
1199
- break;
1200
- case enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.StopStorageGridChargeV1:
1201
- await this.handleStopGridCharge(entry);
1474
+ case enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.SetStorageScheduleV1:
1475
+ this.handleSetStorageScheduleAck(entry);
1202
1476
  break;
1203
- case enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.SetStorageDischargeLimitV1:
1204
- await this.handleSetDischargeLimit(entry);
1477
+ case enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.StartCalibrationV1:
1478
+ await this.handleStartCalibration(entry);
1205
1479
  break;
1206
- case enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.SetStorageChargeLimitV1:
1207
- await this.handleSetChargeLimit(entry);
1480
+ case enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.StopCalibrationV1:
1481
+ await this.handleStopCalibration(entry);
1208
1482
  break;
1209
1483
  }
1210
1484
  }
@@ -1213,114 +1487,112 @@ class SunspecBattery extends BaseSunspecDevice {
1213
1487
  }
1214
1488
  })();
1215
1489
  }
1216
- 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) {
1217
1499
  if (!this.isConnected() || !this.applianceId) {
1218
1500
  this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, "Not connected");
1219
1501
  return;
1220
1502
  }
1221
- console.log(`Battery ${this.applianceId}: handling StartStorageGridChargeV1 (powerLimitW=${msg.data.powerLimitW})`);
1222
- // Read current state for logging and rollback
1223
- const controls = await this.getBatteryControls();
1224
- console.log(`Battery ${this.applianceId}: current state - chaGriSet=${controls?.chaGriSet}, wChaMax=${controls?.wChaMax}, storCtlMod=${controls?.storCtlMod}`);
1225
- const originalChaGriSet = controls?.chaGriSet;
1226
- const originalWChaMax = controls?.wChaMax;
1227
- // Step 1: Enable grid charging (Register 17: chaGriSet=GRID)
1228
- const step1 = await this.enableGridCharging(true);
1229
- if (!step1) {
1230
- this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to enable grid charging');
1231
- return;
1232
- }
1233
- // Step 2: Set charging power (Register 2: wChaMax)
1234
- const step2 = await this.setChargingPower(msg.data.powerLimitW);
1235
- if (!step2) {
1236
- // Rollback step 1
1237
- await this.writeBatteryControls({ chaGriSet: originalChaGriSet });
1238
- this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to set charging power');
1503
+ this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Accepted);
1504
+ }
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) {
1239
1512
  return;
1240
1513
  }
1241
- // Step 3: Set storage mode to CHARGE (Register 5: storCtlMod=0x0001)
1242
- const step3 = await this.setStorageMode(sunspec_interfaces_js_1.SunspecStorageMode.CHARGE);
1243
- if (!step3) {
1244
- // Rollback steps 1+2
1245
- await this.writeBatteryControls({ chaGriSet: originalChaGriSet, wChaMax: originalWChaMax });
1246
- this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to set storage mode to charge');
1514
+ const service = this.buildSnapshotService((snapshot, reason) => this.restoreBatterySnapshot(snapshot, reason));
1515
+ if (!service) {
1247
1516
  return;
1248
1517
  }
1249
- 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`);
1250
1521
  }
1251
- async handleStopGridCharge(msg) {
1522
+ async handleStartCalibration(msg) {
1252
1523
  if (!this.isConnected() || !this.applianceId) {
1253
- this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.NotSupported);
1524
+ this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Not connected');
1254
1525
  return;
1255
1526
  }
1256
- console.log(`Battery ${this.applianceId}: handling StopStorageGridChargeV1`);
1257
- // Read current state for logging and rollback
1258
- const controls = await this.getBatteryControls();
1259
- console.log(`Battery ${this.applianceId}: current state - chaGriSet=${controls?.chaGriSet}, storCtlMod=${controls?.storCtlMod}`);
1260
- const originalChaGriSet = controls?.chaGriSet;
1261
- // Step 1: Disable grid charging (Register 17: chaGriSet=PV)
1262
- const step1 = await this.enableGridCharging(false);
1263
- if (!step1) {
1264
- this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to disable grid charging');
1265
- return;
1527
+ if (!this.snapshotService) {
1528
+ await this.initSnapshotService();
1266
1529
  }
1267
- // Step 2: Set storage mode to AUTO (Register 5: storCtlMod=0x0003)
1268
- const step2 = await this.setStorageMode(sunspec_interfaces_js_1.SunspecStorageMode.AUTO);
1269
- if (!step2) {
1270
- // Rollback step 1
1271
- await this.writeBatteryControls({ chaGriSet: originalChaGriSet });
1272
- this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to set storage mode to auto');
1530
+ if (!this.snapshotService) {
1531
+ this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Snapshot service unavailable');
1273
1532
  return;
1274
1533
  }
1275
- this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Accepted);
1276
- }
1277
- async handleSetDischargeLimit(msg) {
1278
- if (!this.isConnected() || !this.applianceId) {
1279
- this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.NotSupported);
1534
+ if (this.snapshotService.isCalibrating()) {
1535
+ console.log(`Battery ${this.applianceId}: calibration already active — ack idempotently`);
1536
+ this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Accepted);
1280
1537
  return;
1281
1538
  }
1282
- console.log(`Battery ${this.applianceId}: handling SetStorageDischargeLimitV1 (dischargeLimitW=${msg.data.dischargeLimitW})`);
1283
- // Read current state to get wChaMax for percentage conversion
1284
- const controls = await this.getBatteryControls();
1285
- console.log(`Battery ${this.applianceId}: current state - outWRte=${controls?.outWRte}, wChaMax=${controls?.wChaMax}`);
1286
- if (!controls?.wChaMax) {
1287
- this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to read wChaMax for discharge limit conversion');
1539
+ console.log(`Battery ${this.applianceId}: handling StartCalibrationV1`);
1540
+ const controls = await this.sunspecClient.readBatteryControls(this.unitId);
1541
+ if (!controls) {
1542
+ this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to read battery controls for snapshot');
1288
1543
  return;
1289
1544
  }
1290
- // Convert watts to percentage of WDisChaMax (using wChaMax), clamped to 0-100%
1291
- const dischargeLimitPercent = Math.min(100, Math.max(0, (msg.data.dischargeLimitW / controls.wChaMax) * 100));
1292
- console.log(`Battery ${this.applianceId}: calculated discharge limit: ${dischargeLimitPercent.toFixed(1)}% (${msg.data.dischargeLimitW}W / ${controls.wChaMax}W)`);
1293
- // Set discharge limit (Register 12: outWRte)
1294
- const success = await this.writeBatteryControls({ outWRte: dischargeLimitPercent });
1295
- if (!success) {
1296
- this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to set discharge limit');
1545
+ try {
1546
+ await this.snapshotService.startCalibration(controls);
1547
+ }
1548
+ catch (error) {
1549
+ this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, `Failed to persist snapshot: ${error}`);
1297
1550
  return;
1298
1551
  }
1299
1552
  this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Accepted);
1300
1553
  }
1301
- async handleSetChargeLimit(msg) {
1302
- if (!this.isConnected() || !this.applianceId) {
1303
- this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.NotSupported);
1554
+ async handleStopCalibration(msg) {
1555
+ if (!this.applianceId) {
1556
+ this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Not initialized');
1304
1557
  return;
1305
1558
  }
1306
- console.log(`Battery ${this.applianceId}: handling SetStorageChargeLimitV1 (chargeLimitW=${msg.data.chargeLimitW})`);
1307
- // Read current state to get wChaMax for percentage conversion
1308
- const controls = await this.getBatteryControls();
1309
- console.log(`Battery ${this.applianceId}: current state - inWRte=${controls?.inWRte}, wChaMax=${controls?.wChaMax}`);
1310
- if (!controls?.wChaMax) {
1311
- this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to read wChaMax for charge limit conversion');
1312
- return;
1559
+ console.log(`Battery ${this.applianceId}: handling StopCalibrationV1`);
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();
1565
+ this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Accepted);
1566
+ }
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;
1575
+ const partial = {};
1576
+ const WRITABLE_BATTERY_FIELDS = ['storCtlMod', 'chaGriSet', 'wChaMax', 'inWRte', 'outWRte', 'minRsvPct'];
1577
+ for (const field of WRITABLE_BATTERY_FIELDS) {
1578
+ if (snapshot.modifiedFields.includes(field) && source[field] !== undefined) {
1579
+ partial[field] = source[field];
1580
+ }
1313
1581
  }
1314
- // Convert watts to percentage of WChaMax, clamped to 0-100%
1315
- const chargeLimitPercent = Math.min(100, Math.max(0, (msg.data.chargeLimitW / controls.wChaMax) * 100));
1316
- console.log(`Battery ${this.applianceId}: calculated charge limit: ${chargeLimitPercent.toFixed(1)}% (${msg.data.chargeLimitW}W / ${controls.wChaMax}W)`);
1317
- // Set charge limit (Register 13: inWRte)
1318
- const success = await this.writeBatteryControls({ inWRte: chargeLimitPercent });
1319
- if (!success) {
1320
- this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to set charge limit');
1582
+ if (Object.keys(partial).length === 0) {
1583
+ console.log(`Battery ${this.applianceId}: calibration ${reason} no modified fields to restore`);
1321
1584
  return;
1322
1585
  }
1323
- this.sendCommandAcknowledge(msg.id, msg.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Accepted);
1586
+ console.log(`Battery ${this.applianceId}: calibration ${reason} — restoring [${Object.keys(partial).join(', ')}]`);
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}`);
1595
+ }
1324
1596
  }
1325
1597
  }
1326
1598
  exports.SunspecBattery = SunspecBattery;
@@ -1397,11 +1669,13 @@ class SunspecMeter extends BaseSunspecDevice {
1397
1669
  console.error(`Failed to update meter appliance: ${error}`);
1398
1670
  }
1399
1671
  }
1672
+ this.startDataBusListening();
1400
1673
  }
1401
1674
  /**
1402
1675
  * Disconnect from the meter and update appliance state
1403
1676
  */
1404
1677
  async disconnect() {
1678
+ this.stopDataBusListening();
1405
1679
  if (this.applianceId) {
1406
1680
  try {
1407
1681
  await this.applianceManager.updateApplianceState(this.applianceId, enyo_appliance_js_1.EnyoApplianceConnectionType.Connector, enyo_appliance_js_1.EnyoApplianceStateEnum.Offline);
@@ -1413,6 +1687,35 @@ class SunspecMeter extends BaseSunspecDevice {
1413
1687
  // Close just this meter's unit; other devices on the same network device stay open.
1414
1688
  await this.sunspecClient.disconnectUnit(this.unitId);
1415
1689
  }
1690
+ /**
1691
+ * Meter does not implement calibration; it only subscribes to Start/StopCalibrationV1 to
1692
+ * answer NotSupported (per the data-bus contract that every command must be acknowledged).
1693
+ */
1694
+ startDataBusListening() {
1695
+ if (this.dataBusListenerId) {
1696
+ return;
1697
+ }
1698
+ this.dataBus = this.energyApp.useDataBus();
1699
+ this.dataBusListenerId = this.dataBus.listenForMessages([
1700
+ enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.StartCalibrationV1,
1701
+ enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.StopCalibrationV1,
1702
+ ], (entry) => this.handleMeterCommand(entry));
1703
+ console.log(`Meter ${this.applianceId}: started data bus listening (listener ${this.dataBusListenerId})`);
1704
+ }
1705
+ stopDataBusListening() {
1706
+ if (this.dataBusListenerId && this.dataBus) {
1707
+ this.dataBus.unsubscribe(this.dataBusListenerId);
1708
+ console.log(`Meter ${this.applianceId}: stopped data bus listening (listener ${this.dataBusListenerId})`);
1709
+ }
1710
+ this.dataBusListenerId = undefined;
1711
+ this.dataBus = undefined;
1712
+ }
1713
+ handleMeterCommand(entry) {
1714
+ if (entry.applianceId !== this.applianceId) {
1715
+ return;
1716
+ }
1717
+ this.sendCommandAcknowledge(entry.id, entry.message, enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.NotSupported, 'Meter does not support calibration');
1718
+ }
1416
1719
  /**
1417
1720
  * Update meter data and return data bus messages
1418
1721
  */