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