@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.
- 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 +305 -215
- package/dist/cjs/sunspec-devices.d.cts +129 -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 +129 -19
- package/dist/sunspec-devices.js +304 -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,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
|
-
|
|
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
|
}
|
|
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
|
-
|
|
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 —
|
|
1290
|
-
void this.
|
|
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.
|
|
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.
|
|
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);
|
|
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
|
-
|
|
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
|
-
|
|
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');
|
|
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
|
-
|
|
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');
|
|
1542
|
+
const service = this.buildSnapshotService((snapshot, reason) => this.restoreBatterySnapshot(snapshot, reason));
|
|
1543
|
+
if (!service) {
|
|
1453
1544
|
return;
|
|
1454
1545
|
}
|
|
1455
|
-
|
|
1456
|
-
this.
|
|
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.
|
|
1464
|
-
await this.
|
|
1555
|
+
if (!this.snapshotService) {
|
|
1556
|
+
await this.initSnapshotService();
|
|
1465
1557
|
}
|
|
1466
|
-
if (!this.
|
|
1467
|
-
this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, '
|
|
1558
|
+
if (!this.snapshotService) {
|
|
1559
|
+
this.sendCommandAcknowledge(msg.id, msg.message, EnyoCommandAcknowledgeAnswerEnum.Rejected, 'Snapshot service unavailable');
|
|
1468
1560
|
return;
|
|
1469
1561
|
}
|
|
1470
|
-
if (this.
|
|
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.
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
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
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
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
|
}
|