@enyo-energy/sunspec-sdk 0.0.71 → 0.0.73

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 +305 -215
  13. package/dist/cjs/sunspec-devices.d.cts +129 -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 +129 -19
  29. package/dist/sunspec-devices.js +304 -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,21 +848,174 @@ 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
  }
862
+ /**
863
+ * Pure register-presence detection — exported so it can be unit-tested
864
+ * without the full `SunspecBattery` scaffold. Maps each Model 124 writable
865
+ * register to the `EnyoBatteryFeature` it represents:
866
+ *
867
+ * - `chaGriSet` → `GridCharging` (charge source PV/GRID)
868
+ * - `wChaMax` → `ChargeLimitation` (max charge power)
869
+ * - `outWRte` → `DischargeLimitation` (discharge rate %)
870
+ * - `storCtlMod` → `GridDischarging` (closest signal that external discharge
871
+ * control is available; the inverter decides where it goes)
872
+ */
873
+ export function detectFeaturesFromRegisters(batteryData) {
874
+ const features = [];
875
+ if (batteryData?.chaGriSet !== undefined)
876
+ features.push(EnyoBatteryFeature.GridCharging);
877
+ if (batteryData?.wChaMax !== undefined)
878
+ features.push(EnyoBatteryFeature.ChargeLimitation);
879
+ if (batteryData?.outWRte !== undefined)
880
+ features.push(EnyoBatteryFeature.DischargeLimitation);
881
+ if (batteryData?.storCtlMod !== undefined)
882
+ features.push(EnyoBatteryFeature.GridDischarging);
883
+ return features;
884
+ }
885
+ /**
886
+ * Apply the per-feature calibration filter to a register-detected set.
887
+ * Exported for unit-test access to the legacy-result fallback branch.
888
+ *
889
+ * The contract:
890
+ * - No `result` or `result.state !== 'calibrated'` → strip every controllable
891
+ * feature (safe fallback while waiting for calibration).
892
+ * - `result.state === 'calibrated'` with a decodable per-feature payload →
893
+ * publish only the controllable features whose probes passed.
894
+ * - `result.state === 'calibrated'` with **no** decodable payload → legacy
895
+ * data (pre-feature-calibrator SDK). The new calibrator only marks
896
+ * `state=calibrated` when at least one probe passes, so an empty decoded
897
+ * set with `state=calibrated` unambiguously signals legacy results. Publish
898
+ * the full detected set to preserve the old all-or-nothing semantics on
899
+ * upgrade.
900
+ */
901
+ export function filterFeaturesByCalibrationResult(detected, result, controllable) {
902
+ if (!result || result.state !== 'calibrated') {
903
+ return detected.filter(f => !controllable.includes(f));
904
+ }
905
+ const passed = decodeFeatureResults(result.notes);
906
+ if (passed.size === 0)
907
+ return detected;
908
+ return detected.filter(f => !controllable.includes(f) || passed.has(f));
909
+ }
847
910
  /**
848
911
  * Sunspec Battery implementation
849
912
  */
850
913
  export class SunspecBattery extends BaseSunspecDevice {
914
+ featureMode;
851
915
  capabilities;
852
- constructor(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId = 1, port = 502, baseAddress = 40000, capabilities = [], retryConfig, appliance, useTls) {
916
+ snapshotService;
917
+ calibrationDriver;
918
+ batteryCalibrator;
919
+ calibrationResultStore;
920
+ scheduleHandler;
921
+ /**
922
+ * Battery features that imply outbound control writes from the host action-taker.
923
+ * These are stripped from `appliance.battery.features` until the calibration result
924
+ * store says this battery has been successfully calibrated — see
925
+ * {@link computeAdvertisedFeatures}.
926
+ */
927
+ static CONTROLLABLE_FEATURES = [
928
+ EnyoBatteryFeature.GridCharging,
929
+ EnyoBatteryFeature.GridDischarging,
930
+ EnyoBatteryFeature.ChargeLimitation,
931
+ EnyoBatteryFeature.DischargeLimitation,
932
+ ];
933
+ constructor(energyApp, name, networkDevice, sunspecClient, applianceManager, featureMode, unitId = 1, port = 502, baseAddress = 40000, capabilities = [], retryConfig, appliance, useTls) {
853
934
  super(energyApp, name, networkDevice, sunspecClient, applianceManager, unitId, port, baseAddress, retryConfig, appliance, useTls);
935
+ this.featureMode = featureMode;
854
936
  this.capabilities = capabilities;
855
937
  }
938
+ /**
939
+ * Wire the battery into `@enyo-energy/appliance-calibration`'s
940
+ * `BatteryCalibrator` test-charge flow. Call once after {@link connect}.
941
+ *
942
+ * Consumers own the `CalibrationResultStore` (shared across appliances so
943
+ * the persisted result map doesn't get clobbered) and the
944
+ * `CalibrationTrigger` that drives `runCalibration()` on whatever schedule
945
+ * makes sense for the host. Pass overrides for the calibrator's defaults
946
+ * (`testPowerW`, response thresholds, etc.) via `opts.config`.
947
+ */
948
+ configureCalibration(opts) {
949
+ if (this.featureMode.kind !== SunspecBatteryFeatureModeKind.CalibrationBased) {
950
+ throw new Error(`SunspecBattery.configureCalibration: featureMode is '${this.featureMode.kind}', ` +
951
+ `but calibration is only valid in '${SunspecBatteryFeatureModeKind.CalibrationBased}' mode. ` +
952
+ `Pass { kind: SunspecBatteryFeatureModeKind.CalibrationBased } to the constructor.`);
953
+ }
954
+ if (this.batteryCalibrator) {
955
+ return this.batteryCalibrator;
956
+ }
957
+ if (!this.applianceId) {
958
+ throw new Error("SunspecBattery.configureCalibration: connect() must complete first (applianceId required)");
959
+ }
960
+ if (!this.snapshotService) {
961
+ throw new Error("SunspecBattery.configureCalibration: snapshot service not initialized — call connect() first");
962
+ }
963
+ const driver = new SunspecBatteryCalibrationDriver(this.sunspecClient, this.unitId, this.energyApp.useDataBus());
964
+ this.calibrationDriver = driver;
965
+ this.calibrationResultStore = opts.resultStore;
966
+ this.batteryCalibrator = new SunspecBatteryFeatureCalibrator({
967
+ applianceId: this.applianceId,
968
+ driver,
969
+ sunspecClient: this.sunspecClient,
970
+ unitId: this.unitId,
971
+ readBatteryData: () => this.sunspecClient.readBatteryData(this.unitId),
972
+ snapshotService: this.snapshotService,
973
+ resultStore: opts.resultStore,
974
+ config: opts.config,
975
+ });
976
+ return this.batteryCalibrator;
977
+ }
978
+ /**
979
+ * Detect which battery features the device's registers expose. This is the
980
+ * raw, register-only view; the {@link featureMode} configured at
981
+ * construction decides what is actually published — see
982
+ * {@link resolveAdvertisedFeatures}.
983
+ */
984
+ detectFromRegisters(batteryData) {
985
+ return detectFeaturesFromRegisters(batteryData);
986
+ }
987
+ /**
988
+ * Resolve `appliance.battery.features` for the configured mode. Called on
989
+ * every connect() and readData() cycle so that, in calibration-based mode,
990
+ * the controllable features appear as soon as the calibrator flips
991
+ * `isCalibrated` (no synchronous push).
992
+ */
993
+ resolveAdvertisedFeatures(batteryData) {
994
+ const allow = this.featureMode.allowedFeatures;
995
+ const intersect = (xs) => allow ? xs.filter(f => allow.includes(f)) : xs;
996
+ switch (this.featureMode.kind) {
997
+ case SunspecBatteryFeatureModeKind.Disabled:
998
+ return allow ? [...allow] : [];
999
+ case SunspecBatteryFeatureModeKind.RegisterBased:
1000
+ return intersect(this.detectFromRegisters(batteryData));
1001
+ case SunspecBatteryFeatureModeKind.CalibrationBased: {
1002
+ const detected = intersect(this.detectFromRegisters(batteryData));
1003
+ const result = this.applianceId
1004
+ ? this.calibrationResultStore?.getResult(this.applianceId)
1005
+ : undefined;
1006
+ return filterFeaturesByCalibrationResult(detected, result, SunspecBattery.CONTROLLABLE_FEATURES);
1007
+ }
1008
+ }
1009
+ }
1010
+ /**
1011
+ * Returns the per-battery `BatteryCalibrator` created by
1012
+ * {@link configureCalibration}, or `undefined` if calibration was never
1013
+ * configured. Consumers register the returned instance with their own
1014
+ * `CalibrationTrigger`.
1015
+ */
1016
+ getBatteryCalibrator() {
1017
+ return this.batteryCalibrator;
1018
+ }
856
1019
  /**
857
1020
  * Connect to the battery and create/update the appliance
858
1021
  */
@@ -869,13 +1032,7 @@ export class SunspecBattery extends BaseSunspecDevice {
869
1032
  const commonData = await this.sunspecClient.readCommonBlock(this.unitId);
870
1033
  const batteryData = await this.sunspecClient.readBatteryData(this.unitId);
871
1034
  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
- }
1035
+ const features = this.resolveAdvertisedFeatures(batteryData);
879
1036
  const activeChargeLimitW = this.computeActiveChargeLimitW(batteryData);
880
1037
  const activeDischargeLimitW = this.computeActiveDischargeLimitW(batteryData);
881
1038
  // Create or update appliance (skip if an existing appliance was provided)
@@ -942,6 +1099,11 @@ export class SunspecBattery extends BaseSunspecDevice {
942
1099
  }
943
1100
  async disconnect() {
944
1101
  this.stopDataBusListening();
1102
+ if (this.calibrationDriver) {
1103
+ this.calibrationDriver.stop();
1104
+ this.calibrationDriver = undefined;
1105
+ this.batteryCalibrator = undefined;
1106
+ }
945
1107
  if (this.applianceId) {
946
1108
  try {
947
1109
  await this.applianceManager.updateApplianceState(this.applianceId, EnyoApplianceConnectionType.Connector, EnyoApplianceStateEnum.Offline);
@@ -1010,6 +1172,11 @@ export class SunspecBattery extends BaseSunspecDevice {
1010
1172
  else if (!advancedBatteryModel) {
1011
1173
  batteryPowerW = mpptBatteryPowerW;
1012
1174
  }
1175
+ // Feed the calibration driver's synchronous power cache. No-op when calibration
1176
+ // was never configured.
1177
+ if (batteryPowerW !== undefined && this.calibrationDriver) {
1178
+ this.calibrationDriver.updateBatteryPowerCache(batteryPowerW);
1179
+ }
1013
1180
  const batteryMessage = {
1014
1181
  id: randomUUID(),
1015
1182
  message: EnyoDataBusMessageEnum.BatteryValuesUpdateV1,
@@ -1033,6 +1200,9 @@ export class SunspecBattery extends BaseSunspecDevice {
1033
1200
  if (this.applianceId) {
1034
1201
  const appliance = await this.applianceManager.findApplianceById(this.applianceId);
1035
1202
  const storageMode = this.determineStorageMode(batteryData);
1203
+ // Republish features each cycle so the controllable set appears as soon
1204
+ // as the calibrator flips isCalibrated — see resolveAdvertisedFeatures.
1205
+ const features = this.resolveAdvertisedFeatures(batteryData);
1036
1206
  await this.applianceManager.updateAppliance(this.applianceId, {
1037
1207
  battery: {
1038
1208
  ...appliance?.battery,
@@ -1041,6 +1211,7 @@ export class SunspecBattery extends BaseSunspecDevice {
1041
1211
  maxDischargePowerW: batteryData && batteryData.wChaMax !== undefined && batteryData.outWRte !== undefined ? batteryData.wChaMax * batteryData.outWRte : undefined,
1042
1212
  activeChargeLimitW: this.computeActiveChargeLimitW(batteryData),
1043
1213
  activeDischargeLimitW: this.computeActiveDischargeLimitW(batteryData),
1214
+ features,
1044
1215
  gridChargingEnabled: batteryData?.chaGriSet === 1
1045
1216
  }
1046
1217
  });
@@ -1286,17 +1457,27 @@ export class SunspecBattery extends BaseSunspecDevice {
1286
1457
  if (this.dataBusListenerId) {
1287
1458
  return;
1288
1459
  }
1289
- // Fire and forget — calibration service hydration must not block listening setup.
1290
- void this.initCalibrationService('battery');
1460
+ // Fire and forget — snapshot service hydration must not block listener setup.
1461
+ void this.initSnapshotService();
1291
1462
  this.dataBus = this.energyApp.useDataBus();
1292
1463
  this.dataBusListenerId = this.dataBus.listenForMessages([
1293
- EnyoDataBusMessageEnum.StartStorageGridChargeV1,
1294
- EnyoDataBusMessageEnum.StopStorageGridChargeV1,
1295
- EnyoDataBusMessageEnum.SetStorageDischargeLimitV1,
1296
- EnyoDataBusMessageEnum.SetStorageChargeLimitV1,
1464
+ EnyoDataBusMessageEnum.SetStorageScheduleV1,
1297
1465
  EnyoDataBusMessageEnum.StartCalibrationV1,
1298
1466
  EnyoDataBusMessageEnum.StopCalibrationV1,
1299
1467
  ], (entry) => this.handleStorageCommand(entry));
1468
+ if (!this.applianceId) {
1469
+ throw new Error("SunspecBattery.startDataBusListening: applianceId required — call connect() first.");
1470
+ }
1471
+ this.scheduleHandler = new SunspecBatteryScheduleHandler({
1472
+ dataBus: this.dataBus,
1473
+ interval: this.energyApp.useInterval(),
1474
+ storage: this.energyApp.useStorage(),
1475
+ applianceId: this.applianceId,
1476
+ sunspecClient: this.sunspecClient,
1477
+ unitId: this.unitId,
1478
+ getSnapshotService: () => this.snapshotService,
1479
+ onErrorCallback: (err) => console.warn(`Battery ${this.applianceId}: schedule handler rejected input: ${err.message}`),
1480
+ });
1300
1481
  console.log(`Battery ${this.applianceId}: started data bus listening (listener ${this.dataBusListenerId})`);
1301
1482
  }
1302
1483
  /**
@@ -1309,6 +1490,7 @@ export class SunspecBattery extends BaseSunspecDevice {
1309
1490
  }
1310
1491
  this.dataBusListenerId = undefined;
1311
1492
  this.dataBus = undefined;
1493
+ this.scheduleHandler?.dispose();
1312
1494
  }
1313
1495
  handleStorageCommand(entry) {
1314
1496
  if (entry.applianceId !== this.applianceId) {
@@ -1317,17 +1499,8 @@ export class SunspecBattery extends BaseSunspecDevice {
1317
1499
  void (async () => {
1318
1500
  try {
1319
1501
  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);
1502
+ case EnyoDataBusMessageEnum.SetStorageScheduleV1:
1503
+ this.handleSetStorageScheduleAck(entry);
1331
1504
  break;
1332
1505
  case EnyoDataBusMessageEnum.StartCalibrationV1:
1333
1506
  await this.handleStartCalibration(entry);
@@ -1342,132 +1515,51 @@ export class SunspecBattery extends BaseSunspecDevice {
1342
1515
  }
1343
1516
  })();
1344
1517
  }
1345
- async handleStartGridCharge(msg) {
1518
+ /**
1519
+ * Acknowledge a SetStorageScheduleV1 message. The actual schedule application
1520
+ * is owned by `this.scheduleHandler` (subscribed independently via its own
1521
+ * data-bus listener registered in its constructor). The ack reports "received
1522
+ * & queued" rather than "applied" — schedule entries play out over time, and
1523
+ * per-entry write failures land in `console.error` via the handler's
1524
+ * onChange wrapper.
1525
+ */
1526
+ handleSetStorageScheduleAck(msg) {
1346
1527
  if (!this.isConnected() || !this.applianceId) {
1347
1528
  this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, "Not connected");
1348
1529
  return;
1349
1530
  }
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
1531
  this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Accepted);
1432
1532
  }
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');
1533
+ /**
1534
+ * Lazily construct the per-appliance {@link SnapshotService}. Reloads any
1535
+ * persisted snapshot from storage so an in-flight calibration survives
1536
+ * process restarts. Idempotent.
1537
+ */
1538
+ async initSnapshotService() {
1539
+ if (this.snapshotService) {
1444
1540
  return;
1445
1541
  }
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');
1542
+ const service = this.buildSnapshotService((snapshot, reason) => this.restoreBatterySnapshot(snapshot, reason));
1543
+ if (!service) {
1453
1544
  return;
1454
1545
  }
1455
- await this.calibrationService?.recordModification(['inWRte']);
1456
- this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Accepted);
1546
+ this.snapshotService = service;
1547
+ await this.snapshotService.initialize();
1548
+ console.log(`Battery ${this.applianceId}: snapshot service initialized`);
1457
1549
  }
1458
1550
  async handleStartCalibration(msg) {
1459
1551
  if (!this.isConnected() || !this.applianceId) {
1460
1552
  this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Not connected');
1461
1553
  return;
1462
1554
  }
1463
- if (!this.calibrationService) {
1464
- await this.initCalibrationService('battery');
1555
+ if (!this.snapshotService) {
1556
+ await this.initSnapshotService();
1465
1557
  }
1466
- if (!this.calibrationService) {
1467
- this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Calibration service unavailable');
1558
+ if (!this.snapshotService) {
1559
+ this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Snapshot service unavailable');
1468
1560
  return;
1469
1561
  }
1470
- if (this.calibrationService.isCalibrating()) {
1562
+ if (this.snapshotService.isCalibrating()) {
1471
1563
  console.log(`Battery ${this.applianceId}: calibration already active — ack idempotently`);
1472
1564
  this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Accepted);
1473
1565
  return;
@@ -1479,11 +1571,7 @@ export class SunspecBattery extends BaseSunspecDevice {
1479
1571
  return;
1480
1572
  }
1481
1573
  try {
1482
- await this.calibrationService.startCalibration({
1483
- deviceType: 'battery',
1484
- unitId: this.unitId,
1485
- batteryControls: controls,
1486
- });
1574
+ await this.snapshotService.startCalibration(controls);
1487
1575
  }
1488
1576
  catch (error) {
1489
1577
  this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, `Failed to persist snapshot: ${error}`);
@@ -1497,27 +1585,22 @@ export class SunspecBattery extends BaseSunspecDevice {
1497
1585
  return;
1498
1586
  }
1499
1587
  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
- }
1588
+ // SnapshotService.stopCalibration fires `restoreBatterySnapshot` internally
1589
+ // with reason="stop" before returning. Failures from the restore callback are
1590
+ // logged inside the callback (the persisted snapshot is already gone — see
1591
+ // library notes) so we always ack Accepted here.
1592
+ await this.snapshotService?.stopCalibration();
1512
1593
  this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Accepted);
1513
1594
  }
1514
- async restoreFromCalibrationSnapshot(snapshot, reason) {
1515
- if (snapshot.deviceType !== 'battery' || !snapshot.batteryControls) {
1516
- return;
1517
- }
1518
- const source = snapshot.batteryControls;
1595
+ /**
1596
+ * `onRestore` callback for the battery's {@link SnapshotService}. Writes only the
1597
+ * subset of writable battery fields touched during the calibration. Errors are
1598
+ * caught and logged — by the time this fires the snapshot is gone from storage,
1599
+ * so a throw would just propagate into the void.
1600
+ */
1601
+ async restoreBatterySnapshot(snapshot, reason) {
1602
+ const source = snapshot.payload;
1519
1603
  const partial = {};
1520
- // Whitelist of battery fields that writeBatteryControls actually writes (Model 124).
1521
1604
  const WRITABLE_BATTERY_FIELDS = ['storCtlMod', 'chaGriSet', 'wChaMax', 'inWRte', 'outWRte', 'minRsvPct'];
1522
1605
  for (const field of WRITABLE_BATTERY_FIELDS) {
1523
1606
  if (snapshot.modifiedFields.includes(field) && source[field] !== undefined) {
@@ -1529,9 +1612,14 @@ export class SunspecBattery extends BaseSunspecDevice {
1529
1612
  return;
1530
1613
  }
1531
1614
  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');
1615
+ try {
1616
+ const ok = await this.sunspecClient.writeBatteryControls(this.unitId, partial);
1617
+ if (!ok) {
1618
+ console.error(`Battery ${this.applianceId}: calibration ${reason} — writeBatteryControls returned false`);
1619
+ }
1620
+ }
1621
+ catch (error) {
1622
+ console.error(`Battery ${this.applianceId}: calibration ${reason} — writeBatteryControls threw: ${error}`);
1535
1623
  }
1536
1624
  }
1537
1625
  }