@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
@@ -1,8 +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 { 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";
6
11
  import { EnyoSourceEnum } from "@enyo-energy/energy-app-sdk/dist/types/enyo-source.enum.js";
7
12
  import { EnyoMeterApplianceAvailableFeaturesEnum } from "@enyo-energy/energy-app-sdk/dist/types/enyo-meter-appliance.js";
8
13
  import { EnyoBatteryFeature, EnyoBatteryStorageMode } from "@enyo-energy/energy-app-sdk/dist/types/enyo-battery-appliance.js";
@@ -96,6 +101,13 @@ export class BaseSunspecDevice {
96
101
  dataBus;
97
102
  retryManager;
98
103
  consecutiveReconnectFailures = 0;
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-";
99
111
  constructor(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId = 1, port = 502, baseAddress = 40000, retryConfig, appliance, useTls) {
100
112
  this.energyApp = energyApp;
101
113
  this.name = name;
@@ -249,6 +261,20 @@ export class BaseSunspecDevice {
249
261
  console.log(`${this.constructor.name} ${this.applianceId}: sending ${answer} for ${acknowledgeMessage} (messageId=${messageId}${rejectionReason ? `, reason=${rejectionReason}` : ''})`);
250
262
  this.dataBus.sendMessage([ackMessage]);
251
263
  }
264
+ /**
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.
268
+ */
269
+ buildSnapshotService(onRestore) {
270
+ if (!this.applianceId) {
271
+ return undefined;
272
+ }
273
+ const storage = new SunspecCalibrationStorage(this.energyApp.useStorage());
274
+ return new SnapshotService(storage, this.applianceId, onRestore, {
275
+ storageKeyPrefix: BaseSunspecDevice.CALIBRATION_SNAPSHOT_KEY_PREFIX,
276
+ });
277
+ }
252
278
  }
253
279
  /**
254
280
  * Sunspec Inverter implementation using dynamic model discovery
@@ -259,6 +285,7 @@ export class SunspecInverter extends BaseSunspecDevice {
259
285
  static CONNECTION_FAULT_THRESHOLD = 3;
260
286
  storage;
261
287
  errorState = { activeCodes: [], lastStatus: 'healthy' };
288
+ snapshotService;
262
289
  constructor(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId = 1, port = 502, baseAddress = 40000, capabilities = [], retryConfig, appliance, useTls) {
263
290
  super(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId, port, baseAddress, retryConfig, appliance, useTls);
264
291
  this.capabilities = capabilities;
@@ -671,8 +698,16 @@ export class SunspecInverter extends BaseSunspecDevice {
671
698
  if (this.dataBusListenerId) {
672
699
  return;
673
700
  }
701
+ // Fire and forget — snapshot service hydration must not block listener setup.
702
+ // Listener registration before await ensures we don't miss messages that arrive
703
+ // during the initial load.
704
+ void this.initSnapshotService();
674
705
  this.dataBus = this.energyApp.useDataBus();
675
- this.dataBusListenerId = this.dataBus.listenForMessages([EnyoDataBusMessageEnum.SetInverterFeedInLimitV1], (entry) => this.handleInverterCommand(entry));
706
+ this.dataBusListenerId = this.dataBus.listenForMessages([
707
+ EnyoDataBusMessageEnum.SetInverterFeedInLimitV1,
708
+ EnyoDataBusMessageEnum.StartCalibrationV1,
709
+ EnyoDataBusMessageEnum.StopCalibrationV1,
710
+ ], (entry) => this.handleInverterCommand(entry));
676
711
  console.log(`Inverter ${this.applianceId}: started data bus listening (listener ${this.dataBusListenerId})`);
677
712
  }
678
713
  /**
@@ -692,8 +727,16 @@ export class SunspecInverter extends BaseSunspecDevice {
692
727
  }
693
728
  void (async () => {
694
729
  try {
695
- if (entry.message === EnyoDataBusMessageEnum.SetInverterFeedInLimitV1) {
696
- await this.handleSetFeedInLimit(entry);
730
+ switch (entry.message) {
731
+ case EnyoDataBusMessageEnum.SetInverterFeedInLimitV1:
732
+ await this.handleSetFeedInLimit(entry);
733
+ break;
734
+ case EnyoDataBusMessageEnum.StartCalibrationV1:
735
+ await this.handleStartCalibration(entry);
736
+ break;
737
+ case EnyoDataBusMessageEnum.StopCalibrationV1:
738
+ await this.handleStopCalibration(entry);
739
+ break;
697
740
  }
698
741
  }
699
742
  catch (error) {
@@ -717,18 +760,229 @@ export class SunspecInverter extends BaseSunspecDevice {
717
760
  this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to set feed-in limit');
718
761
  return;
719
762
  }
763
+ // setFeedInLimit writes WMaxLimPct + WMaxLim_Ena (see SunspecModbusClient.setFeedInLimit).
764
+ // Record both so an active calibration can roll them back on stop.
765
+ await this.snapshotService?.recordModification(['WMaxLimPct', 'WMaxLim_Ena']);
766
+ this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Accepted);
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
+ }
785
+ async handleStartCalibration(msg) {
786
+ if (!this.isConnected() || !this.applianceId) {
787
+ this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Not connected');
788
+ return;
789
+ }
790
+ if (!this.snapshotService) {
791
+ await this.initSnapshotService();
792
+ }
793
+ if (!this.snapshotService) {
794
+ this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Snapshot service unavailable');
795
+ return;
796
+ }
797
+ if (this.snapshotService.isCalibrating()) {
798
+ console.log(`Inverter ${this.applianceId}: calibration already active — ack idempotently`);
799
+ this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Accepted);
800
+ return;
801
+ }
802
+ console.log(`Inverter ${this.applianceId}: handling StartCalibrationV1`);
803
+ const controls = await this.sunspecClient.readInverterControls(this.unitId);
804
+ if (!controls) {
805
+ this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to read inverter controls for snapshot');
806
+ return;
807
+ }
808
+ try {
809
+ await this.snapshotService.startCalibration(controls);
810
+ }
811
+ catch (error) {
812
+ this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, `Failed to persist snapshot: ${error}`);
813
+ return;
814
+ }
815
+ this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Accepted);
816
+ }
817
+ async handleStopCalibration(msg) {
818
+ if (!this.applianceId) {
819
+ this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Not initialized');
820
+ return;
821
+ }
822
+ console.log(`Inverter ${this.applianceId}: handling StopCalibrationV1`);
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();
720
828
  this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Accepted);
721
829
  }
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;
839
+ const partial = {};
840
+ const WRITABLE_INVERTER_FIELDS = ['Conn', 'WMaxLimPct', 'WMaxLim_Ena', 'OutPFSet', 'OutPFSet_Ena'];
841
+ for (const field of WRITABLE_INVERTER_FIELDS) {
842
+ if (snapshot.modifiedFields.includes(field) && source[field] !== undefined) {
843
+ partial[field] = source[field];
844
+ }
845
+ }
846
+ if (Object.keys(partial).length === 0) {
847
+ console.log(`Inverter ${this.applianceId}: calibration ${reason} — no modified fields to restore`);
848
+ return;
849
+ }
850
+ console.log(`Inverter ${this.applianceId}: calibration ${reason} — restoring [${Object.keys(partial).join(', ')}]`);
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}`);
859
+ }
860
+ }
722
861
  }
723
862
  /**
724
863
  * Sunspec Battery implementation
725
864
  */
726
865
  export class SunspecBattery extends BaseSunspecDevice {
866
+ featureMode;
727
867
  capabilities;
728
- 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) {
729
886
  super(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId, port, baseAddress, retryConfig, appliance, useTls);
887
+ this.featureMode = featureMode;
730
888
  this.capabilities = capabilities;
731
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
+ }
732
986
  /**
733
987
  * Connect to the battery and create/update the appliance
734
988
  */
@@ -745,13 +999,7 @@ export class SunspecBattery extends BaseSunspecDevice {
745
999
  const commonData = await this.sunspecClient.readCommonBlock(this.unitId);
746
1000
  const batteryData = await this.sunspecClient.readBatteryData(this.unitId);
747
1001
  const storageMode = this.determineStorageMode(batteryData);
748
- const features = [];
749
- if (batteryData?.chaGriSet !== undefined) {
750
- features.push(EnyoBatteryFeature.GridCharging);
751
- }
752
- if (batteryData?.wChaMax !== undefined) {
753
- features.push(EnyoBatteryFeature.ChargeLimitation);
754
- }
1002
+ const features = this.resolveAdvertisedFeatures(batteryData);
755
1003
  const activeChargeLimitW = this.computeActiveChargeLimitW(batteryData);
756
1004
  const activeDischargeLimitW = this.computeActiveDischargeLimitW(batteryData);
757
1005
  // Create or update appliance (skip if an existing appliance was provided)
@@ -818,6 +1066,11 @@ export class SunspecBattery extends BaseSunspecDevice {
818
1066
  }
819
1067
  async disconnect() {
820
1068
  this.stopDataBusListening();
1069
+ if (this.calibrationDriver) {
1070
+ this.calibrationDriver.stop();
1071
+ this.calibrationDriver = undefined;
1072
+ this.batteryCalibrator = undefined;
1073
+ }
821
1074
  if (this.applianceId) {
822
1075
  try {
823
1076
  await this.applianceManager.updateApplianceState(this.applianceId, EnyoApplianceConnectionType.Connector, EnyoApplianceStateEnum.Offline);
@@ -886,6 +1139,11 @@ export class SunspecBattery extends BaseSunspecDevice {
886
1139
  else if (!advancedBatteryModel) {
887
1140
  batteryPowerW = mpptBatteryPowerW;
888
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
+ }
889
1147
  const batteryMessage = {
890
1148
  id: randomUUID(),
891
1149
  message: EnyoDataBusMessageEnum.BatteryValuesUpdateV1,
@@ -909,6 +1167,9 @@ export class SunspecBattery extends BaseSunspecDevice {
909
1167
  if (this.applianceId) {
910
1168
  const appliance = await this.applianceManager.findApplianceById(this.applianceId);
911
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);
912
1173
  await this.applianceManager.updateAppliance(this.applianceId, {
913
1174
  battery: {
914
1175
  ...appliance?.battery,
@@ -917,6 +1178,7 @@ export class SunspecBattery extends BaseSunspecDevice {
917
1178
  maxDischargePowerW: batteryData && batteryData.wChaMax !== undefined && batteryData.outWRte !== undefined ? batteryData.wChaMax * batteryData.outWRte : undefined,
918
1179
  activeChargeLimitW: this.computeActiveChargeLimitW(batteryData),
919
1180
  activeDischargeLimitW: this.computeActiveDischargeLimitW(batteryData),
1181
+ features,
920
1182
  gridChargingEnabled: batteryData?.chaGriSet === 1
921
1183
  }
922
1184
  });
@@ -1162,13 +1424,27 @@ export class SunspecBattery extends BaseSunspecDevice {
1162
1424
  if (this.dataBusListenerId) {
1163
1425
  return;
1164
1426
  }
1427
+ // Fire and forget — snapshot service hydration must not block listener setup.
1428
+ void this.initSnapshotService();
1165
1429
  this.dataBus = this.energyApp.useDataBus();
1166
1430
  this.dataBusListenerId = this.dataBus.listenForMessages([
1167
- EnyoDataBusMessageEnum.StartStorageGridChargeV1,
1168
- EnyoDataBusMessageEnum.StopStorageGridChargeV1,
1169
- EnyoDataBusMessageEnum.SetStorageDischargeLimitV1,
1170
- EnyoDataBusMessageEnum.SetStorageChargeLimitV1
1431
+ EnyoDataBusMessageEnum.SetStorageScheduleV1,
1432
+ EnyoDataBusMessageEnum.StartCalibrationV1,
1433
+ EnyoDataBusMessageEnum.StopCalibrationV1,
1171
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
+ });
1172
1448
  console.log(`Battery ${this.applianceId}: started data bus listening (listener ${this.dataBusListenerId})`);
1173
1449
  }
1174
1450
  /**
@@ -1181,6 +1457,7 @@ export class SunspecBattery extends BaseSunspecDevice {
1181
1457
  }
1182
1458
  this.dataBusListenerId = undefined;
1183
1459
  this.dataBus = undefined;
1460
+ this.scheduleHandler?.dispose();
1184
1461
  }
1185
1462
  handleStorageCommand(entry) {
1186
1463
  if (entry.applianceId !== this.applianceId) {
@@ -1189,17 +1466,14 @@ export class SunspecBattery extends BaseSunspecDevice {
1189
1466
  void (async () => {
1190
1467
  try {
1191
1468
  switch (entry.message) {
1192
- case EnyoDataBusMessageEnum.StartStorageGridChargeV1:
1193
- await this.handleStartGridCharge(entry);
1194
- break;
1195
- case EnyoDataBusMessageEnum.StopStorageGridChargeV1:
1196
- await this.handleStopGridCharge(entry);
1469
+ case EnyoDataBusMessageEnum.SetStorageScheduleV1:
1470
+ this.handleSetStorageScheduleAck(entry);
1197
1471
  break;
1198
- case EnyoDataBusMessageEnum.SetStorageDischargeLimitV1:
1199
- await this.handleSetDischargeLimit(entry);
1472
+ case EnyoDataBusMessageEnum.StartCalibrationV1:
1473
+ await this.handleStartCalibration(entry);
1200
1474
  break;
1201
- case EnyoDataBusMessageEnum.SetStorageChargeLimitV1:
1202
- await this.handleSetChargeLimit(entry);
1475
+ case EnyoDataBusMessageEnum.StopCalibrationV1:
1476
+ await this.handleStopCalibration(entry);
1203
1477
  break;
1204
1478
  }
1205
1479
  }
@@ -1208,114 +1482,112 @@ export class SunspecBattery extends BaseSunspecDevice {
1208
1482
  }
1209
1483
  })();
1210
1484
  }
1211
- 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) {
1212
1494
  if (!this.isConnected() || !this.applianceId) {
1213
1495
  this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, "Not connected");
1214
1496
  return;
1215
1497
  }
1216
- console.log(`Battery ${this.applianceId}: handling StartStorageGridChargeV1 (powerLimitW=${msg.data.powerLimitW})`);
1217
- // Read current state for logging and rollback
1218
- const controls = await this.getBatteryControls();
1219
- console.log(`Battery ${this.applianceId}: current state - chaGriSet=${controls?.chaGriSet}, wChaMax=${controls?.wChaMax}, storCtlMod=${controls?.storCtlMod}`);
1220
- const originalChaGriSet = controls?.chaGriSet;
1221
- const originalWChaMax = controls?.wChaMax;
1222
- // Step 1: Enable grid charging (Register 17: chaGriSet=GRID)
1223
- const step1 = await this.enableGridCharging(true);
1224
- if (!step1) {
1225
- this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to enable grid charging');
1226
- return;
1227
- }
1228
- // Step 2: Set charging power (Register 2: wChaMax)
1229
- const step2 = await this.setChargingPower(msg.data.powerLimitW);
1230
- if (!step2) {
1231
- // Rollback step 1
1232
- await this.writeBatteryControls({ chaGriSet: originalChaGriSet });
1233
- this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to set charging power');
1498
+ this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Accepted);
1499
+ }
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) {
1234
1507
  return;
1235
1508
  }
1236
- // Step 3: Set storage mode to CHARGE (Register 5: storCtlMod=0x0001)
1237
- const step3 = await this.setStorageMode(SunspecStorageMode.CHARGE);
1238
- if (!step3) {
1239
- // Rollback steps 1+2
1240
- await this.writeBatteryControls({ chaGriSet: originalChaGriSet, wChaMax: originalWChaMax });
1241
- this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to set storage mode to charge');
1509
+ const service = this.buildSnapshotService((snapshot, reason) => this.restoreBatterySnapshot(snapshot, reason));
1510
+ if (!service) {
1242
1511
  return;
1243
1512
  }
1244
- 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`);
1245
1516
  }
1246
- async handleStopGridCharge(msg) {
1517
+ async handleStartCalibration(msg) {
1247
1518
  if (!this.isConnected() || !this.applianceId) {
1248
- this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.NotSupported);
1519
+ this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Not connected');
1249
1520
  return;
1250
1521
  }
1251
- console.log(`Battery ${this.applianceId}: handling StopStorageGridChargeV1`);
1252
- // Read current state for logging and rollback
1253
- const controls = await this.getBatteryControls();
1254
- console.log(`Battery ${this.applianceId}: current state - chaGriSet=${controls?.chaGriSet}, storCtlMod=${controls?.storCtlMod}`);
1255
- const originalChaGriSet = controls?.chaGriSet;
1256
- // Step 1: Disable grid charging (Register 17: chaGriSet=PV)
1257
- const step1 = await this.enableGridCharging(false);
1258
- if (!step1) {
1259
- this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to disable grid charging');
1260
- return;
1522
+ if (!this.snapshotService) {
1523
+ await this.initSnapshotService();
1261
1524
  }
1262
- // Step 2: Set storage mode to AUTO (Register 5: storCtlMod=0x0003)
1263
- const step2 = await this.setStorageMode(SunspecStorageMode.AUTO);
1264
- if (!step2) {
1265
- // Rollback step 1
1266
- await this.writeBatteryControls({ chaGriSet: originalChaGriSet });
1267
- this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to set storage mode to auto');
1525
+ if (!this.snapshotService) {
1526
+ this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Snapshot service unavailable');
1268
1527
  return;
1269
1528
  }
1270
- this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Accepted);
1271
- }
1272
- async handleSetDischargeLimit(msg) {
1273
- if (!this.isConnected() || !this.applianceId) {
1274
- this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.NotSupported);
1529
+ if (this.snapshotService.isCalibrating()) {
1530
+ console.log(`Battery ${this.applianceId}: calibration already active — ack idempotently`);
1531
+ this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Accepted);
1275
1532
  return;
1276
1533
  }
1277
- console.log(`Battery ${this.applianceId}: handling SetStorageDischargeLimitV1 (dischargeLimitW=${msg.data.dischargeLimitW})`);
1278
- // Read current state to get wChaMax for percentage conversion
1279
- const controls = await this.getBatteryControls();
1280
- console.log(`Battery ${this.applianceId}: current state - outWRte=${controls?.outWRte}, wChaMax=${controls?.wChaMax}`);
1281
- if (!controls?.wChaMax) {
1282
- this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to read wChaMax for discharge limit conversion');
1534
+ console.log(`Battery ${this.applianceId}: handling StartCalibrationV1`);
1535
+ const controls = await this.sunspecClient.readBatteryControls(this.unitId);
1536
+ if (!controls) {
1537
+ this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to read battery controls for snapshot');
1283
1538
  return;
1284
1539
  }
1285
- // Convert watts to percentage of WDisChaMax (using wChaMax), clamped to 0-100%
1286
- const dischargeLimitPercent = Math.min(100, Math.max(0, (msg.data.dischargeLimitW / controls.wChaMax) * 100));
1287
- console.log(`Battery ${this.applianceId}: calculated discharge limit: ${dischargeLimitPercent.toFixed(1)}% (${msg.data.dischargeLimitW}W / ${controls.wChaMax}W)`);
1288
- // Set discharge limit (Register 12: outWRte)
1289
- const success = await this.writeBatteryControls({ outWRte: dischargeLimitPercent });
1290
- if (!success) {
1291
- this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to set discharge limit');
1540
+ try {
1541
+ await this.snapshotService.startCalibration(controls);
1542
+ }
1543
+ catch (error) {
1544
+ this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, `Failed to persist snapshot: ${error}`);
1292
1545
  return;
1293
1546
  }
1294
1547
  this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Accepted);
1295
1548
  }
1296
- async handleSetChargeLimit(msg) {
1297
- if (!this.isConnected() || !this.applianceId) {
1298
- this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.NotSupported);
1549
+ async handleStopCalibration(msg) {
1550
+ if (!this.applianceId) {
1551
+ this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Not initialized');
1299
1552
  return;
1300
1553
  }
1301
- console.log(`Battery ${this.applianceId}: handling SetStorageChargeLimitV1 (chargeLimitW=${msg.data.chargeLimitW})`);
1302
- // Read current state to get wChaMax for percentage conversion
1303
- const controls = await this.getBatteryControls();
1304
- console.log(`Battery ${this.applianceId}: current state - inWRte=${controls?.inWRte}, wChaMax=${controls?.wChaMax}`);
1305
- if (!controls?.wChaMax) {
1306
- this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to read wChaMax for charge limit conversion');
1307
- return;
1554
+ console.log(`Battery ${this.applianceId}: handling StopCalibrationV1`);
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();
1560
+ this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Accepted);
1561
+ }
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;
1570
+ const partial = {};
1571
+ const WRITABLE_BATTERY_FIELDS = ['storCtlMod', 'chaGriSet', 'wChaMax', 'inWRte', 'outWRte', 'minRsvPct'];
1572
+ for (const field of WRITABLE_BATTERY_FIELDS) {
1573
+ if (snapshot.modifiedFields.includes(field) && source[field] !== undefined) {
1574
+ partial[field] = source[field];
1575
+ }
1308
1576
  }
1309
- // Convert watts to percentage of WChaMax, clamped to 0-100%
1310
- const chargeLimitPercent = Math.min(100, Math.max(0, (msg.data.chargeLimitW / controls.wChaMax) * 100));
1311
- console.log(`Battery ${this.applianceId}: calculated charge limit: ${chargeLimitPercent.toFixed(1)}% (${msg.data.chargeLimitW}W / ${controls.wChaMax}W)`);
1312
- // Set charge limit (Register 13: inWRte)
1313
- const success = await this.writeBatteryControls({ inWRte: chargeLimitPercent });
1314
- if (!success) {
1315
- this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Failed to set charge limit');
1577
+ if (Object.keys(partial).length === 0) {
1578
+ console.log(`Battery ${this.applianceId}: calibration ${reason} no modified fields to restore`);
1316
1579
  return;
1317
1580
  }
1318
- this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Accepted);
1581
+ console.log(`Battery ${this.applianceId}: calibration ${reason} — restoring [${Object.keys(partial).join(', ')}]`);
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}`);
1590
+ }
1319
1591
  }
1320
1592
  }
1321
1593
  /**
@@ -1391,11 +1663,13 @@ export class SunspecMeter extends BaseSunspecDevice {
1391
1663
  console.error(`Failed to update meter appliance: ${error}`);
1392
1664
  }
1393
1665
  }
1666
+ this.startDataBusListening();
1394
1667
  }
1395
1668
  /**
1396
1669
  * Disconnect from the meter and update appliance state
1397
1670
  */
1398
1671
  async disconnect() {
1672
+ this.stopDataBusListening();
1399
1673
  if (this.applianceId) {
1400
1674
  try {
1401
1675
  await this.applianceManager.updateApplianceState(this.applianceId, EnyoApplianceConnectionType.Connector, EnyoApplianceStateEnum.Offline);
@@ -1407,6 +1681,35 @@ export class SunspecMeter extends BaseSunspecDevice {
1407
1681
  // Close just this meter's unit; other devices on the same network device stay open.
1408
1682
  await this.sunspecClient.disconnectUnit(this.unitId);
1409
1683
  }
1684
+ /**
1685
+ * Meter does not implement calibration; it only subscribes to Start/StopCalibrationV1 to
1686
+ * answer NotSupported (per the data-bus contract that every command must be acknowledged).
1687
+ */
1688
+ startDataBusListening() {
1689
+ if (this.dataBusListenerId) {
1690
+ return;
1691
+ }
1692
+ this.dataBus = this.energyApp.useDataBus();
1693
+ this.dataBusListenerId = this.dataBus.listenForMessages([
1694
+ EnyoDataBusMessageEnum.StartCalibrationV1,
1695
+ EnyoDataBusMessageEnum.StopCalibrationV1,
1696
+ ], (entry) => this.handleMeterCommand(entry));
1697
+ console.log(`Meter ${this.applianceId}: started data bus listening (listener ${this.dataBusListenerId})`);
1698
+ }
1699
+ stopDataBusListening() {
1700
+ if (this.dataBusListenerId && this.dataBus) {
1701
+ this.dataBus.unsubscribe(this.dataBusListenerId);
1702
+ console.log(`Meter ${this.applianceId}: stopped data bus listening (listener ${this.dataBusListenerId})`);
1703
+ }
1704
+ this.dataBusListenerId = undefined;
1705
+ this.dataBus = undefined;
1706
+ }
1707
+ handleMeterCommand(entry) {
1708
+ if (entry.applianceId !== this.applianceId) {
1709
+ return;
1710
+ }
1711
+ this.sendCommandAcknowledge(entry.id, entry.message, EnyoCommandAcknowledgeAnswerEnum.NotSupported, 'Meter does not support calibration');
1712
+ }
1410
1713
  /**
1411
1714
  * Update meter data and return data bus messages
1412
1715
  */