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