@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
@@ -1,9 +1,13 @@
1
- import { SunspecBatteryChargeState, SunspecEnableControl, SunspecInverterCapability, SunspecInverterEvent1, SunspecModelId, SunspecMPPTOperatingState, SunspecStorageMode, SUNSPEC_CONNECTION_LOST_CODE } from "./sunspec-interfaces.js";
1
+ import { SunspecBatteryChargeState, SunspecBatteryFeatureModeKind, SunspecEnableControl, SunspecInverterCapability, SunspecInverterEvent1, SunspecModelId, SunspecMPPTOperatingState, SunspecStorageMode, SUNSPEC_CONNECTION_LOST_CODE } from "./sunspec-interfaces.js";
2
2
  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
+ import { SnapshotService, } from "@enyo-energy/appliance-calibration";
7
+ import { SunspecCalibrationStorage } from "./sunspec-calibration-storage.js";
8
+ import { SunspecBatteryCalibrationDriver } from "./sunspec-battery-calibration-driver.js";
9
+ import { SunspecBatteryScheduleHandler } from "./sunspec-battery-schedule-handler.js";
10
+ import { SunspecBatteryFeatureCalibrator, decodeFeatureResults } from "./sunspec-battery-feature-calibrator.js";
7
11
  import { EnyoSourceEnum } from "@enyo-energy/energy-app-sdk/dist/types/enyo-source.enum.js";
8
12
  import { EnyoMeterApplianceAvailableFeaturesEnum } from "@enyo-energy/energy-app-sdk/dist/types/enyo-meter-appliance.js";
9
13
  import { EnyoBatteryFeature, EnyoBatteryStorageMode } from "@enyo-energy/energy-app-sdk/dist/types/enyo-battery-appliance.js";
@@ -97,7 +101,13 @@ export class BaseSunspecDevice {
97
101
  dataBus;
98
102
  retryManager;
99
103
  consecutiveReconnectFailures = 0;
100
- calibrationService;
104
+ /**
105
+ * Prefix used when persisting calibration snapshots via the library's
106
+ * {@link SnapshotService}. Kept identical to the key the SDK used before
107
+ * the migration to `@enyo-energy/appliance-calibration` so existing
108
+ * snapshots stored by older builds are still picked up on startup.
109
+ */
110
+ static CALIBRATION_SNAPSHOT_KEY_PREFIX = "sunspec-calibration-snapshot-";
101
111
  constructor(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId = 1, port = 502, baseAddress = 40000, retryConfig, appliance, useTls) {
102
112
  this.energyApp = energyApp;
103
113
  this.name = name;
@@ -252,25 +262,18 @@ export class BaseSunspecDevice {
252
262
  this.dataBus.sendMessage([ackMessage]);
253
263
  }
254
264
  /**
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.
265
+ * Build a typed {@link SnapshotService} bound to this appliance's calibration storage,
266
+ * using the legacy SDK storage key prefix so prior installs upgrade seamlessly. Returns
267
+ * undefined if the appliance has not been registered yet.
258
268
  */
259
- async initCalibrationService(deviceType) {
260
- if (this.calibrationService || !this.applianceId) {
261
- return;
269
+ buildSnapshotService(onRestore) {
270
+ if (!this.applianceId) {
271
+ return undefined;
262
272
  }
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.
273
+ const storage = new SunspecCalibrationStorage(this.energyApp.useStorage());
274
+ return new SnapshotService(storage, this.applianceId, onRestore, {
275
+ storageKeyPrefix: BaseSunspecDevice.CALIBRATION_SNAPSHOT_KEY_PREFIX,
276
+ });
274
277
  }
275
278
  }
276
279
  /**
@@ -282,6 +285,7 @@ export class SunspecInverter extends BaseSunspecDevice {
282
285
  static CONNECTION_FAULT_THRESHOLD = 3;
283
286
  storage;
284
287
  errorState = { activeCodes: [], lastStatus: 'healthy' };
288
+ snapshotService;
285
289
  constructor(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId = 1, port = 502, baseAddress = 40000, capabilities = [], retryConfig, appliance, useTls) {
286
290
  super(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId, port, baseAddress, retryConfig, appliance, useTls);
287
291
  this.capabilities = capabilities;
@@ -694,10 +698,10 @@ export class SunspecInverter extends BaseSunspecDevice {
694
698
  if (this.dataBusListenerId) {
695
699
  return;
696
700
  }
697
- // Fire and forget — calibration service hydration must not block listening setup.
701
+ // Fire and forget — snapshot service hydration must not block listener setup.
698
702
  // Listener registration before await ensures we don't miss messages that arrive
699
- // during initial load.
700
- void this.initCalibrationService('inverter');
703
+ // during the initial load.
704
+ void this.initSnapshotService();
701
705
  this.dataBus = this.energyApp.useDataBus();
702
706
  this.dataBusListenerId = this.dataBus.listenForMessages([
703
707
  EnyoDataBusMessageEnum.SetInverterFeedInLimitV1,
@@ -758,22 +762,39 @@ export class SunspecInverter extends BaseSunspecDevice {
758
762
  }
759
763
  // setFeedInLimit writes WMaxLimPct + WMaxLim_Ena (see SunspecModbusClient.setFeedInLimit).
760
764
  // Record both so an active calibration can roll them back on stop.
761
- await this.calibrationService?.recordModification(['WMaxLimPct', 'WMaxLim_Ena']);
765
+ await this.snapshotService?.recordModification(['WMaxLimPct', 'WMaxLim_Ena']);
762
766
  this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Accepted);
763
767
  }
768
+ /**
769
+ * Lazily construct the per-appliance {@link SnapshotService}. Reloads any
770
+ * persisted snapshot from storage so an in-flight calibration survives
771
+ * process restarts. Idempotent.
772
+ */
773
+ async initSnapshotService() {
774
+ if (this.snapshotService) {
775
+ return;
776
+ }
777
+ const service = this.buildSnapshotService((snapshot, reason) => this.restoreInverterSnapshot(snapshot, reason));
778
+ if (!service) {
779
+ return;
780
+ }
781
+ this.snapshotService = service;
782
+ await this.snapshotService.initialize();
783
+ console.log(`Inverter ${this.applianceId}: snapshot service initialized`);
784
+ }
764
785
  async handleStartCalibration(msg) {
765
786
  if (!this.isConnected() || !this.applianceId) {
766
787
  this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Not connected');
767
788
  return;
768
789
  }
769
- if (!this.calibrationService) {
770
- await this.initCalibrationService('inverter');
790
+ if (!this.snapshotService) {
791
+ await this.initSnapshotService();
771
792
  }
772
- if (!this.calibrationService) {
773
- this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Calibration service unavailable');
793
+ if (!this.snapshotService) {
794
+ this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Snapshot service unavailable');
774
795
  return;
775
796
  }
776
- if (this.calibrationService.isCalibrating()) {
797
+ if (this.snapshotService.isCalibrating()) {
777
798
  console.log(`Inverter ${this.applianceId}: calibration already active — ack idempotently`);
778
799
  this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Accepted);
779
800
  return;
@@ -785,11 +806,7 @@ export class SunspecInverter extends BaseSunspecDevice {
785
806
  return;
786
807
  }
787
808
  try {
788
- await this.calibrationService.startCalibration({
789
- deviceType: 'inverter',
790
- unitId: this.unitId,
791
- inverterControls: controls,
792
- });
809
+ await this.snapshotService.startCalibration(controls);
793
810
  }
794
811
  catch (error) {
795
812
  this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, `Failed to persist snapshot: ${error}`);
@@ -803,30 +820,23 @@ export class SunspecInverter extends BaseSunspecDevice {
803
820
  return;
804
821
  }
805
822
  console.log(`Inverter ${this.applianceId}: handling StopCalibrationV1`);
806
- const snapshot = await this.calibrationService?.stopCalibration();
807
- if (!snapshot) {
808
- // No active calibrationtreat 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
- }
823
+ // SnapshotService.stopCalibration fires `restoreInverterSnapshot` internally with
824
+ // reason="stop" before returning. Any restore failure is logged by the callback
825
+ // (the persisted snapshot is already gone by then see library notes) so we
826
+ // always ack Accepted from here.
827
+ await this.snapshotService?.stopCalibration();
819
828
  this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Accepted);
820
829
  }
821
- async restoreFromCalibrationSnapshot(snapshot, reason) {
822
- if (snapshot.deviceType !== 'inverter' || !snapshot.inverterControls) {
823
- return;
824
- }
825
- const source = snapshot.inverterControls;
830
+ /**
831
+ * `onRestore` callback for the inverter's {@link SnapshotService}. Writes only the
832
+ * subset of writable inverter fields that other commands actually touched during the
833
+ * calibration. Catches and logs failures rather than throwing — the snapshot has
834
+ * already been removed from storage by the time this runs, so a throw is unrecoverable
835
+ * and is best logged for operator action.
836
+ */
837
+ async restoreInverterSnapshot(snapshot, reason) {
838
+ const source = snapshot.payload;
826
839
  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
840
  const WRITABLE_INVERTER_FIELDS = ['Conn', 'WMaxLimPct', 'WMaxLim_Ena', 'OutPFSet', 'OutPFSet_Ena'];
831
841
  for (const field of WRITABLE_INVERTER_FIELDS) {
832
842
  if (snapshot.modifiedFields.includes(field) && source[field] !== undefined) {
@@ -838,9 +848,14 @@ export class SunspecInverter extends BaseSunspecDevice {
838
848
  return;
839
849
  }
840
850
  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');
851
+ try {
852
+ const ok = await this.sunspecClient.writeInverterControls(this.unitId, partial);
853
+ if (!ok) {
854
+ console.error(`Inverter ${this.applianceId}: calibration ${reason} — writeInverterControls returned false`);
855
+ }
856
+ }
857
+ catch (error) {
858
+ console.error(`Inverter ${this.applianceId}: calibration ${reason} — writeInverterControls threw: ${error}`);
844
859
  }
845
860
  }
846
861
  }
@@ -848,11 +863,126 @@ export class SunspecInverter extends BaseSunspecDevice {
848
863
  * Sunspec Battery implementation
849
864
  */
850
865
  export class SunspecBattery extends BaseSunspecDevice {
866
+ featureMode;
851
867
  capabilities;
852
- constructor(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId = 1, port = 502, baseAddress = 40000, capabilities = [], retryConfig, appliance, useTls) {
868
+ snapshotService;
869
+ calibrationDriver;
870
+ batteryCalibrator;
871
+ calibrationResultStore;
872
+ scheduleHandler;
873
+ /**
874
+ * Battery features that imply outbound control writes from the host action-taker.
875
+ * These are stripped from `appliance.battery.features` until the calibration result
876
+ * store says this battery has been successfully calibrated — see
877
+ * {@link computeAdvertisedFeatures}.
878
+ */
879
+ static CONTROLLABLE_FEATURES = [
880
+ EnyoBatteryFeature.GridCharging,
881
+ EnyoBatteryFeature.GridDischarging,
882
+ EnyoBatteryFeature.ChargeLimitation,
883
+ EnyoBatteryFeature.DischargeLimitation,
884
+ ];
885
+ constructor(energyApp, name, networkDevice, sunspecClient, applianceManager, featureMode, unitId = 1, port = 502, baseAddress = 40000, capabilities = [], retryConfig, appliance, useTls) {
853
886
  super(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId, port, baseAddress, retryConfig, appliance, useTls);
887
+ this.featureMode = featureMode;
854
888
  this.capabilities = capabilities;
855
889
  }
890
+ /**
891
+ * Wire the battery into `@enyo-energy/appliance-calibration`'s
892
+ * `BatteryCalibrator` test-charge flow. Call once after {@link connect}.
893
+ *
894
+ * Consumers own the `CalibrationResultStore` (shared across appliances so
895
+ * the persisted result map doesn't get clobbered) and the
896
+ * `CalibrationTrigger` that drives `runCalibration()` on whatever schedule
897
+ * makes sense for the host. Pass overrides for the calibrator's defaults
898
+ * (`testPowerW`, response thresholds, etc.) via `opts.config`.
899
+ */
900
+ configureCalibration(opts) {
901
+ if (this.featureMode.kind !== SunspecBatteryFeatureModeKind.CalibrationBased) {
902
+ throw new Error(`SunspecBattery.configureCalibration: featureMode is '${this.featureMode.kind}', ` +
903
+ `but calibration is only valid in '${SunspecBatteryFeatureModeKind.CalibrationBased}' mode. ` +
904
+ `Pass { kind: SunspecBatteryFeatureModeKind.CalibrationBased } to the constructor.`);
905
+ }
906
+ if (this.batteryCalibrator) {
907
+ return this.batteryCalibrator;
908
+ }
909
+ if (!this.applianceId) {
910
+ throw new Error("SunspecBattery.configureCalibration: connect() must complete first (applianceId required)");
911
+ }
912
+ if (!this.snapshotService) {
913
+ throw new Error("SunspecBattery.configureCalibration: snapshot service not initialized — call connect() first");
914
+ }
915
+ const driver = new SunspecBatteryCalibrationDriver(this.sunspecClient, this.unitId, this.energyApp.useDataBus());
916
+ this.calibrationDriver = driver;
917
+ this.calibrationResultStore = opts.resultStore;
918
+ this.batteryCalibrator = new SunspecBatteryFeatureCalibrator({
919
+ applianceId: this.applianceId,
920
+ driver,
921
+ sunspecClient: this.sunspecClient,
922
+ unitId: this.unitId,
923
+ readBatteryData: () => this.sunspecClient.readBatteryData(this.unitId),
924
+ snapshotService: this.snapshotService,
925
+ resultStore: opts.resultStore,
926
+ config: opts.config,
927
+ });
928
+ return this.batteryCalibrator;
929
+ }
930
+ /**
931
+ * Detect which battery features the device's registers expose. This is the
932
+ * raw, register-only view; the {@link featureMode} configured at
933
+ * construction decides what is actually published — see
934
+ * {@link resolveAdvertisedFeatures}.
935
+ */
936
+ detectFromRegisters(batteryData) {
937
+ const features = [];
938
+ if (batteryData?.chaGriSet !== undefined) {
939
+ features.push(EnyoBatteryFeature.GridCharging);
940
+ }
941
+ if (batteryData?.wChaMax !== undefined) {
942
+ features.push(EnyoBatteryFeature.ChargeLimitation);
943
+ }
944
+ return features;
945
+ }
946
+ /**
947
+ * Resolve `appliance.battery.features` for the configured mode. Called on
948
+ * every connect() and readData() cycle so that, in calibration-based mode,
949
+ * the controllable features appear as soon as the calibrator flips
950
+ * `isCalibrated` (no synchronous push).
951
+ */
952
+ resolveAdvertisedFeatures(batteryData) {
953
+ const allow = this.featureMode.allowedFeatures;
954
+ const intersect = (xs) => allow ? xs.filter(f => allow.includes(f)) : xs;
955
+ switch (this.featureMode.kind) {
956
+ case SunspecBatteryFeatureModeKind.Disabled:
957
+ return allow ? [...allow] : [];
958
+ case SunspecBatteryFeatureModeKind.RegisterBased:
959
+ return intersect(this.detectFromRegisters(batteryData));
960
+ case SunspecBatteryFeatureModeKind.CalibrationBased: {
961
+ const detected = intersect(this.detectFromRegisters(batteryData));
962
+ if (!this.applianceId || !this.calibrationResultStore) {
963
+ return detected.filter(f => !SunspecBattery.CONTROLLABLE_FEATURES.includes(f));
964
+ }
965
+ const result = this.calibrationResultStore.getResult(this.applianceId);
966
+ if (!result || result.state !== 'calibrated') {
967
+ return detected.filter(f => !SunspecBattery.CONTROLLABLE_FEATURES.includes(f));
968
+ }
969
+ // Per-feature gating: only publish controllable features whose
970
+ // probe passed during the last calibration. See
971
+ // `decodeFeatureResults` and `SunspecBatteryFeatureCalibrator`.
972
+ const passed = decodeFeatureResults(result.notes);
973
+ return detected.filter(f => !SunspecBattery.CONTROLLABLE_FEATURES.includes(f) || passed.has(f));
974
+ }
975
+ }
976
+ }
977
+ /**
978
+ * Returns the per-battery `BatteryCalibrator` created by
979
+ * {@link configureCalibration}, or `undefined` if calibration was never
980
+ * configured. Consumers register the returned instance with their own
981
+ * `CalibrationTrigger`.
982
+ */
983
+ getBatteryCalibrator() {
984
+ return this.batteryCalibrator;
985
+ }
856
986
  /**
857
987
  * Connect to the battery and create/update the appliance
858
988
  */
@@ -869,13 +999,7 @@ export class SunspecBattery extends BaseSunspecDevice {
869
999
  const commonData = await this.sunspecClient.readCommonBlock(this.unitId);
870
1000
  const batteryData = await this.sunspecClient.readBatteryData(this.unitId);
871
1001
  const storageMode = this.determineStorageMode(batteryData);
872
- const features = [];
873
- if (batteryData?.chaGriSet !== undefined) {
874
- features.push(EnyoBatteryFeature.GridCharging);
875
- }
876
- if (batteryData?.wChaMax !== undefined) {
877
- features.push(EnyoBatteryFeature.ChargeLimitation);
878
- }
1002
+ const features = this.resolveAdvertisedFeatures(batteryData);
879
1003
  const activeChargeLimitW = this.computeActiveChargeLimitW(batteryData);
880
1004
  const activeDischargeLimitW = this.computeActiveDischargeLimitW(batteryData);
881
1005
  // Create or update appliance (skip if an existing appliance was provided)
@@ -942,6 +1066,11 @@ export class SunspecBattery extends BaseSunspecDevice {
942
1066
  }
943
1067
  async disconnect() {
944
1068
  this.stopDataBusListening();
1069
+ if (this.calibrationDriver) {
1070
+ this.calibrationDriver.stop();
1071
+ this.calibrationDriver = undefined;
1072
+ this.batteryCalibrator = undefined;
1073
+ }
945
1074
  if (this.applianceId) {
946
1075
  try {
947
1076
  await this.applianceManager.updateApplianceState(this.applianceId, EnyoApplianceConnectionType.Connector, EnyoApplianceStateEnum.Offline);
@@ -1010,6 +1139,11 @@ export class SunspecBattery extends BaseSunspecDevice {
1010
1139
  else if (!advancedBatteryModel) {
1011
1140
  batteryPowerW = mpptBatteryPowerW;
1012
1141
  }
1142
+ // Feed the calibration driver's synchronous power cache. No-op when calibration
1143
+ // was never configured.
1144
+ if (batteryPowerW !== undefined && this.calibrationDriver) {
1145
+ this.calibrationDriver.updateBatteryPowerCache(batteryPowerW);
1146
+ }
1013
1147
  const batteryMessage = {
1014
1148
  id: randomUUID(),
1015
1149
  message: EnyoDataBusMessageEnum.BatteryValuesUpdateV1,
@@ -1033,6 +1167,9 @@ export class SunspecBattery extends BaseSunspecDevice {
1033
1167
  if (this.applianceId) {
1034
1168
  const appliance = await this.applianceManager.findApplianceById(this.applianceId);
1035
1169
  const storageMode = this.determineStorageMode(batteryData);
1170
+ // Republish features each cycle so the controllable set appears as soon
1171
+ // as the calibrator flips isCalibrated — see resolveAdvertisedFeatures.
1172
+ const features = this.resolveAdvertisedFeatures(batteryData);
1036
1173
  await this.applianceManager.updateAppliance(this.applianceId, {
1037
1174
  battery: {
1038
1175
  ...appliance?.battery,
@@ -1041,6 +1178,7 @@ export class SunspecBattery extends BaseSunspecDevice {
1041
1178
  maxDischargePowerW: batteryData && batteryData.wChaMax !== undefined && batteryData.outWRte !== undefined ? batteryData.wChaMax * batteryData.outWRte : undefined,
1042
1179
  activeChargeLimitW: this.computeActiveChargeLimitW(batteryData),
1043
1180
  activeDischargeLimitW: this.computeActiveDischargeLimitW(batteryData),
1181
+ features,
1044
1182
  gridChargingEnabled: batteryData?.chaGriSet === 1
1045
1183
  }
1046
1184
  });
@@ -1286,17 +1424,27 @@ export class SunspecBattery extends BaseSunspecDevice {
1286
1424
  if (this.dataBusListenerId) {
1287
1425
  return;
1288
1426
  }
1289
- // Fire and forget — calibration service hydration must not block listening setup.
1290
- void this.initCalibrationService('battery');
1427
+ // Fire and forget — snapshot service hydration must not block listener setup.
1428
+ void this.initSnapshotService();
1291
1429
  this.dataBus = this.energyApp.useDataBus();
1292
1430
  this.dataBusListenerId = this.dataBus.listenForMessages([
1293
- EnyoDataBusMessageEnum.StartStorageGridChargeV1,
1294
- EnyoDataBusMessageEnum.StopStorageGridChargeV1,
1295
- EnyoDataBusMessageEnum.SetStorageDischargeLimitV1,
1296
- EnyoDataBusMessageEnum.SetStorageChargeLimitV1,
1431
+ EnyoDataBusMessageEnum.SetStorageScheduleV1,
1297
1432
  EnyoDataBusMessageEnum.StartCalibrationV1,
1298
1433
  EnyoDataBusMessageEnum.StopCalibrationV1,
1299
1434
  ], (entry) => this.handleStorageCommand(entry));
1435
+ if (!this.applianceId) {
1436
+ throw new Error("SunspecBattery.startDataBusListening: applianceId required — call connect() first.");
1437
+ }
1438
+ this.scheduleHandler = new SunspecBatteryScheduleHandler({
1439
+ dataBus: this.dataBus,
1440
+ interval: this.energyApp.useInterval(),
1441
+ storage: this.energyApp.useStorage(),
1442
+ applianceId: this.applianceId,
1443
+ sunspecClient: this.sunspecClient,
1444
+ unitId: this.unitId,
1445
+ getSnapshotService: () => this.snapshotService,
1446
+ onErrorCallback: (err) => console.warn(`Battery ${this.applianceId}: schedule handler rejected input: ${err.message}`),
1447
+ });
1300
1448
  console.log(`Battery ${this.applianceId}: started data bus listening (listener ${this.dataBusListenerId})`);
1301
1449
  }
1302
1450
  /**
@@ -1309,6 +1457,7 @@ export class SunspecBattery extends BaseSunspecDevice {
1309
1457
  }
1310
1458
  this.dataBusListenerId = undefined;
1311
1459
  this.dataBus = undefined;
1460
+ this.scheduleHandler?.dispose();
1312
1461
  }
1313
1462
  handleStorageCommand(entry) {
1314
1463
  if (entry.applianceId !== this.applianceId) {
@@ -1317,17 +1466,8 @@ export class SunspecBattery extends BaseSunspecDevice {
1317
1466
  void (async () => {
1318
1467
  try {
1319
1468
  switch (entry.message) {
1320
- case EnyoDataBusMessageEnum.StartStorageGridChargeV1:
1321
- await this.handleStartGridCharge(entry);
1322
- break;
1323
- case EnyoDataBusMessageEnum.StopStorageGridChargeV1:
1324
- await this.handleStopGridCharge(entry);
1325
- break;
1326
- case EnyoDataBusMessageEnum.SetStorageDischargeLimitV1:
1327
- await this.handleSetDischargeLimit(entry);
1328
- break;
1329
- case EnyoDataBusMessageEnum.SetStorageChargeLimitV1:
1330
- await this.handleSetChargeLimit(entry);
1469
+ case EnyoDataBusMessageEnum.SetStorageScheduleV1:
1470
+ this.handleSetStorageScheduleAck(entry);
1331
1471
  break;
1332
1472
  case EnyoDataBusMessageEnum.StartCalibrationV1:
1333
1473
  await this.handleStartCalibration(entry);
@@ -1342,132 +1482,51 @@ export class SunspecBattery extends BaseSunspecDevice {
1342
1482
  }
1343
1483
  })();
1344
1484
  }
1345
- async handleStartGridCharge(msg) {
1485
+ /**
1486
+ * Acknowledge a SetStorageScheduleV1 message. The actual schedule application
1487
+ * is owned by `this.scheduleHandler` (subscribed independently via its own
1488
+ * data-bus listener registered in its constructor). The ack reports "received
1489
+ * & queued" rather than "applied" — schedule entries play out over time, and
1490
+ * per-entry write failures land in `console.error` via the handler's
1491
+ * onChange wrapper.
1492
+ */
1493
+ handleSetStorageScheduleAck(msg) {
1346
1494
  if (!this.isConnected() || !this.applianceId) {
1347
1495
  this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, "Not connected");
1348
1496
  return;
1349
1497
  }
1350
- console.log(`Battery ${this.applianceId}: handling StartStorageGridChargeV1 (powerLimitW=${msg.data.powerLimitW})`);
1351
- // Read current state for logging and rollback
1352
- const controls = await this.getBatteryControls();
1353
- console.log(`Battery ${this.applianceId}: current state - chaGriSet=${controls?.chaGriSet}, wChaMax=${controls?.wChaMax}, storCtlMod=${controls?.storCtlMod}`);
1354
- const originalChaGriSet = controls?.chaGriSet;
1355
- const originalWChaMax = controls?.wChaMax;
1356
- // Step 1: Enable grid charging (Register 17: chaGriSet=GRID)
1357
- const step1 = await this.enableGridCharging(true);
1358
- if (!step1) {
1359
- this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to enable grid charging');
1360
- return;
1361
- }
1362
- // Step 2: Set charging power (Register 2: wChaMax)
1363
- const step2 = await this.setChargingPower(msg.data.powerLimitW);
1364
- if (!step2) {
1365
- // Rollback step 1
1366
- await this.writeBatteryControls({ chaGriSet: originalChaGriSet });
1367
- this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to set charging power');
1368
- return;
1369
- }
1370
- // Step 3: Set storage mode to CHARGE (Register 5: storCtlMod=0x0001)
1371
- const step3 = await this.setStorageMode(SunspecStorageMode.CHARGE);
1372
- if (!step3) {
1373
- // Rollback steps 1+2
1374
- await this.writeBatteryControls({ chaGriSet: originalChaGriSet, wChaMax: originalWChaMax });
1375
- this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to set storage mode to charge');
1376
- return;
1377
- }
1378
- await this.calibrationService?.recordModification(['chaGriSet', 'wChaMax', 'storCtlMod']);
1379
- this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Accepted);
1380
- }
1381
- async handleStopGridCharge(msg) {
1382
- if (!this.isConnected() || !this.applianceId) {
1383
- this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.NotSupported);
1384
- return;
1385
- }
1386
- console.log(`Battery ${this.applianceId}: handling StopStorageGridChargeV1`);
1387
- // Read current state for logging and rollback
1388
- const controls = await this.getBatteryControls();
1389
- console.log(`Battery ${this.applianceId}: current state - chaGriSet=${controls?.chaGriSet}, storCtlMod=${controls?.storCtlMod}`);
1390
- const originalChaGriSet = controls?.chaGriSet;
1391
- // Step 1: Disable grid charging (Register 17: chaGriSet=PV)
1392
- const step1 = await this.enableGridCharging(false);
1393
- if (!step1) {
1394
- this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to disable grid charging');
1395
- return;
1396
- }
1397
- // Step 2: Set storage mode to AUTO (Register 5: storCtlMod=0x0003)
1398
- const step2 = await this.setStorageMode(SunspecStorageMode.AUTO);
1399
- if (!step2) {
1400
- // Rollback step 1
1401
- await this.writeBatteryControls({ chaGriSet: originalChaGriSet });
1402
- this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to set storage mode to auto');
1403
- return;
1404
- }
1405
- await this.calibrationService?.recordModification(['chaGriSet', 'storCtlMod']);
1406
- this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Accepted);
1407
- }
1408
- async handleSetDischargeLimit(msg) {
1409
- if (!this.isConnected() || !this.applianceId) {
1410
- this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.NotSupported);
1411
- return;
1412
- }
1413
- console.log(`Battery ${this.applianceId}: handling SetStorageDischargeLimitV1 (dischargeLimitW=${msg.data.dischargeLimitW})`);
1414
- // Read current state to get wChaMax for percentage conversion
1415
- const controls = await this.getBatteryControls();
1416
- console.log(`Battery ${this.applianceId}: current state - outWRte=${controls?.outWRte}, wChaMax=${controls?.wChaMax}`);
1417
- if (!controls?.wChaMax) {
1418
- this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to read wChaMax for discharge limit conversion');
1419
- return;
1420
- }
1421
- // Convert watts to percentage of WDisChaMax (using wChaMax), clamped to 0-100%
1422
- const dischargeLimitPercent = Math.min(100, Math.max(0, (msg.data.dischargeLimitW / controls.wChaMax) * 100));
1423
- console.log(`Battery ${this.applianceId}: calculated discharge limit: ${dischargeLimitPercent.toFixed(1)}% (${msg.data.dischargeLimitW}W / ${controls.wChaMax}W)`);
1424
- // Set discharge limit (Register 12: outWRte)
1425
- const success = await this.writeBatteryControls({ outWRte: dischargeLimitPercent });
1426
- if (!success) {
1427
- this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to set discharge limit');
1428
- return;
1429
- }
1430
- await this.calibrationService?.recordModification(['outWRte']);
1431
1498
  this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Accepted);
1432
1499
  }
1433
- async handleSetChargeLimit(msg) {
1434
- if (!this.isConnected() || !this.applianceId) {
1435
- this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.NotSupported);
1436
- return;
1437
- }
1438
- console.log(`Battery ${this.applianceId}: handling SetStorageChargeLimitV1 (chargeLimitW=${msg.data.chargeLimitW})`);
1439
- // Read current state to get wChaMax for percentage conversion
1440
- const controls = await this.getBatteryControls();
1441
- console.log(`Battery ${this.applianceId}: current state - inWRte=${controls?.inWRte}, wChaMax=${controls?.wChaMax}`);
1442
- if (!controls?.wChaMax) {
1443
- this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to read wChaMax for charge limit conversion');
1500
+ /**
1501
+ * Lazily construct the per-appliance {@link SnapshotService}. Reloads any
1502
+ * persisted snapshot from storage so an in-flight calibration survives
1503
+ * process restarts. Idempotent.
1504
+ */
1505
+ async initSnapshotService() {
1506
+ if (this.snapshotService) {
1444
1507
  return;
1445
1508
  }
1446
- // Convert watts to percentage of WChaMax, clamped to 0-100%
1447
- const chargeLimitPercent = Math.min(100, Math.max(0, (msg.data.chargeLimitW / controls.wChaMax) * 100));
1448
- console.log(`Battery ${this.applianceId}: calculated charge limit: ${chargeLimitPercent.toFixed(1)}% (${msg.data.chargeLimitW}W / ${controls.wChaMax}W)`);
1449
- // Set charge limit (Register 13: inWRte)
1450
- const success = await this.writeBatteryControls({ inWRte: chargeLimitPercent });
1451
- if (!success) {
1452
- this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to set charge limit');
1509
+ const service = this.buildSnapshotService((snapshot, reason) => this.restoreBatterySnapshot(snapshot, reason));
1510
+ if (!service) {
1453
1511
  return;
1454
1512
  }
1455
- await this.calibrationService?.recordModification(['inWRte']);
1456
- this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Accepted);
1513
+ this.snapshotService = service;
1514
+ await this.snapshotService.initialize();
1515
+ console.log(`Battery ${this.applianceId}: snapshot service initialized`);
1457
1516
  }
1458
1517
  async handleStartCalibration(msg) {
1459
1518
  if (!this.isConnected() || !this.applianceId) {
1460
1519
  this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Not connected');
1461
1520
  return;
1462
1521
  }
1463
- if (!this.calibrationService) {
1464
- await this.initCalibrationService('battery');
1522
+ if (!this.snapshotService) {
1523
+ await this.initSnapshotService();
1465
1524
  }
1466
- if (!this.calibrationService) {
1467
- this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Calibration service unavailable');
1525
+ if (!this.snapshotService) {
1526
+ this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Snapshot service unavailable');
1468
1527
  return;
1469
1528
  }
1470
- if (this.calibrationService.isCalibrating()) {
1529
+ if (this.snapshotService.isCalibrating()) {
1471
1530
  console.log(`Battery ${this.applianceId}: calibration already active — ack idempotently`);
1472
1531
  this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Accepted);
1473
1532
  return;
@@ -1479,11 +1538,7 @@ export class SunspecBattery extends BaseSunspecDevice {
1479
1538
  return;
1480
1539
  }
1481
1540
  try {
1482
- await this.calibrationService.startCalibration({
1483
- deviceType: 'battery',
1484
- unitId: this.unitId,
1485
- batteryControls: controls,
1486
- });
1541
+ await this.snapshotService.startCalibration(controls);
1487
1542
  }
1488
1543
  catch (error) {
1489
1544
  this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, `Failed to persist snapshot: ${error}`);
@@ -1497,27 +1552,22 @@ export class SunspecBattery extends BaseSunspecDevice {
1497
1552
  return;
1498
1553
  }
1499
1554
  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
- }
1555
+ // SnapshotService.stopCalibration fires `restoreBatterySnapshot` internally
1556
+ // with reason="stop" before returning. Failures from the restore callback are
1557
+ // logged inside the callback (the persisted snapshot is already gone — see
1558
+ // library notes) so we always ack Accepted here.
1559
+ await this.snapshotService?.stopCalibration();
1512
1560
  this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Accepted);
1513
1561
  }
1514
- async restoreFromCalibrationSnapshot(snapshot, reason) {
1515
- if (snapshot.deviceType !== 'battery' || !snapshot.batteryControls) {
1516
- return;
1517
- }
1518
- const source = snapshot.batteryControls;
1562
+ /**
1563
+ * `onRestore` callback for the battery's {@link SnapshotService}. Writes only the
1564
+ * subset of writable battery fields touched during the calibration. Errors are
1565
+ * caught and logged — by the time this fires the snapshot is gone from storage,
1566
+ * so a throw would just propagate into the void.
1567
+ */
1568
+ async restoreBatterySnapshot(snapshot, reason) {
1569
+ const source = snapshot.payload;
1519
1570
  const partial = {};
1520
- // Whitelist of battery fields that writeBatteryControls actually writes (Model 124).
1521
1571
  const WRITABLE_BATTERY_FIELDS = ['storCtlMod', 'chaGriSet', 'wChaMax', 'inWRte', 'outWRte', 'minRsvPct'];
1522
1572
  for (const field of WRITABLE_BATTERY_FIELDS) {
1523
1573
  if (snapshot.modifiedFields.includes(field) && source[field] !== undefined) {
@@ -1529,9 +1579,14 @@ export class SunspecBattery extends BaseSunspecDevice {
1529
1579
  return;
1530
1580
  }
1531
1581
  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');
1582
+ try {
1583
+ const ok = await this.sunspecClient.writeBatteryControls(this.unitId, partial);
1584
+ if (!ok) {
1585
+ console.error(`Battery ${this.applianceId}: calibration ${reason} — writeBatteryControls returned false`);
1586
+ }
1587
+ }
1588
+ catch (error) {
1589
+ console.error(`Battery ${this.applianceId}: calibration ${reason} — writeBatteryControls threw: ${error}`);
1535
1590
  }
1536
1591
  }
1537
1592
  }