@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
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SunspecBatteryFeatureCalibrator = void 0;
|
|
4
|
+
exports.decodeFeatureResults = decodeFeatureResults;
|
|
5
|
+
const appliance_calibration_1 = require("@enyo-energy/appliance-calibration");
|
|
6
|
+
const enyo_battery_appliance_js_1 = require("@enyo-energy/energy-app-sdk/dist/types/enyo-battery-appliance.js");
|
|
7
|
+
const sunspec_interfaces_js_1 = require("./sunspec-interfaces.cjs");
|
|
8
|
+
/**
|
|
9
|
+
* SoC band each probe needs to operate in. Outside the band the probe returns
|
|
10
|
+
* `not-supported` instead of running — there's no useful signal when the
|
|
11
|
+
* battery has no headroom (or no energy) for the test.
|
|
12
|
+
*/
|
|
13
|
+
const CHARGE_PROBE_MAX_SOC_PCT = 90;
|
|
14
|
+
const DISCHARGE_PROBE_MIN_SOC_PCT = 20;
|
|
15
|
+
/**
|
|
16
|
+
* `inWRte` / `outWRte` value used by the limit probes. Combined with
|
|
17
|
+
* `wChaMax = testPowerW`, the expected observed power is half of `testPowerW`.
|
|
18
|
+
*/
|
|
19
|
+
const LIMIT_PROBE_RATE_PERCENT = 50;
|
|
20
|
+
/**
|
|
21
|
+
* SunSpec-specific calibrator that exercises each of the four controllable
|
|
22
|
+
* battery features individually and records per-feature outcomes in the
|
|
23
|
+
* library's existing `CalibrationResult.notes` field (JSON-encoded).
|
|
24
|
+
*
|
|
25
|
+
* Sequence per session:
|
|
26
|
+
*
|
|
27
|
+
* 1. captureSnapshot — read writable Model 124 registers.
|
|
28
|
+
* 2. snapshotService.startCalibration — arms auto-restore.
|
|
29
|
+
* 3. enterCalibrationMode — driver no-op for SunSpec (returns "accepted").
|
|
30
|
+
* 4. probeGridCharge → battery power AND grid power rise above threshold.
|
|
31
|
+
* 5. probeChargeLimitation → inWRte=50, observe charge power ≈ testPowerW/2.
|
|
32
|
+
* 6. probeDischargeLimitation → outWRte=50, observe discharge power ≈ testPowerW/2.
|
|
33
|
+
* 7. probeGridDischarge → storCtlMod=DISCHARGE, observe meter export.
|
|
34
|
+
* 8. exitCalibrationMode + snapshotService.stopCalibration → restoreFromSnapshot fires.
|
|
35
|
+
*
|
|
36
|
+
* Charge probes run before discharge probes so the battery has energy to
|
|
37
|
+
* discharge with. Each probe ends with the registers reset to a neutral
|
|
38
|
+
* state (storCtlMod=AUTO, chaGriSet=PV, inWRte/outWRte=100, wChaMax=snapshot)
|
|
39
|
+
* so the next probe starts cleanly even though the snapshot service's
|
|
40
|
+
* restore only fires at the very end.
|
|
41
|
+
*
|
|
42
|
+
* The aggregated `CalibrationResult.state` is:
|
|
43
|
+
*
|
|
44
|
+
* - `calibrated` if at least one probe passed,
|
|
45
|
+
* - `not-supported` if every probe was `not-supported` (e.g. SoC out of band
|
|
46
|
+
* for both charge AND discharge),
|
|
47
|
+
* - `failed` otherwise.
|
|
48
|
+
*
|
|
49
|
+
* `isCalibrated(applianceId)` therefore reads as "the SDK probed this device
|
|
50
|
+
* and at least something is controllable" — keep using it as the broad
|
|
51
|
+
* command-emission gate. The per-feature breakdown in `notes` is what
|
|
52
|
+
* `SunspecBattery.resolveAdvertisedFeatures` consults to decide which
|
|
53
|
+
* controllable features to publish.
|
|
54
|
+
*/
|
|
55
|
+
class SunspecBatteryFeatureCalibrator extends appliance_calibration_1.BatteryCalibrator {
|
|
56
|
+
sunspecDriver;
|
|
57
|
+
sunspecClient;
|
|
58
|
+
unitId;
|
|
59
|
+
readBatteryData;
|
|
60
|
+
probeConfig;
|
|
61
|
+
probeClock;
|
|
62
|
+
// Local refs to the same instances the parent stores privately. We keep
|
|
63
|
+
// our own copies because the parent's fields aren't accessible from a
|
|
64
|
+
// subclass.
|
|
65
|
+
probeSnapshotService;
|
|
66
|
+
probeResultStore;
|
|
67
|
+
isProbeRunning = false;
|
|
68
|
+
constructor(options) {
|
|
69
|
+
super(options);
|
|
70
|
+
this.sunspecDriver = options.driver;
|
|
71
|
+
this.sunspecClient = options.sunspecClient;
|
|
72
|
+
this.unitId = options.unitId;
|
|
73
|
+
this.readBatteryData = options.readBatteryData;
|
|
74
|
+
this.probeConfig = { ...appliance_calibration_1.DEFAULT_BATTERY_CALIBRATOR_CONFIG, ...options.config };
|
|
75
|
+
this.probeSnapshotService = options.snapshotService;
|
|
76
|
+
this.probeResultStore = options.resultStore;
|
|
77
|
+
this.probeClock = options.clock ?? {
|
|
78
|
+
now: () => Date.now(),
|
|
79
|
+
sleep: (ms) => new Promise(resolve => setTimeout(resolve, ms)),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
async runCalibration() {
|
|
83
|
+
if (this.isProbeRunning) {
|
|
84
|
+
throw new Error(`Battery calibration already in progress for ${this.applianceId}`);
|
|
85
|
+
}
|
|
86
|
+
this.isProbeRunning = true;
|
|
87
|
+
const recordedAtIso = new Date(this.probeClock.now()).toISOString();
|
|
88
|
+
console.log(`[SunspecBatteryFeatureCalibrator ${this.applianceId}] Starting per-feature calibration`);
|
|
89
|
+
let snapshotStarted = false;
|
|
90
|
+
try {
|
|
91
|
+
const snapshot = await this.sunspecDriver.captureSnapshot();
|
|
92
|
+
await this.probeSnapshotService.startCalibration(snapshot);
|
|
93
|
+
snapshotStarted = true;
|
|
94
|
+
const enterAck = await this.sunspecDriver.enterCalibrationMode();
|
|
95
|
+
if (enterAck !== "accepted") {
|
|
96
|
+
return await this.persistResult({
|
|
97
|
+
state: enterAck === "not-supported" ? "not-supported" : "failed",
|
|
98
|
+
recordedAtIso,
|
|
99
|
+
notes: `enterCalibrationMode returned ${enterAck}`,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
const batteryData = await this.readBatteryData();
|
|
103
|
+
const soc = batteryData?.soc ?? batteryData?.chaState;
|
|
104
|
+
const featureResults = {};
|
|
105
|
+
const diagnostics = {};
|
|
106
|
+
// Charge probes first so the battery has energy to discharge later.
|
|
107
|
+
const probes = [
|
|
108
|
+
{ feature: enyo_battery_appliance_js_1.EnyoBatteryFeature.GridCharging, run: () => this.probeGridCharge(soc, snapshot) },
|
|
109
|
+
{ feature: enyo_battery_appliance_js_1.EnyoBatteryFeature.ChargeLimitation, run: () => this.probeChargeLimitation(soc, snapshot) },
|
|
110
|
+
{ feature: enyo_battery_appliance_js_1.EnyoBatteryFeature.DischargeLimitation, run: () => this.probeDischargeLimitation(soc, snapshot) },
|
|
111
|
+
{ feature: enyo_battery_appliance_js_1.EnyoBatteryFeature.GridDischarging, run: () => this.probeGridDischarge(soc, snapshot) },
|
|
112
|
+
];
|
|
113
|
+
for (const probe of probes) {
|
|
114
|
+
const outcome = await probe.run();
|
|
115
|
+
featureResults[probe.feature] = outcome.verdict;
|
|
116
|
+
diagnostics[probe.feature] = outcome.note;
|
|
117
|
+
}
|
|
118
|
+
const verdicts = Object.values(featureResults);
|
|
119
|
+
const aggregate = verdicts.includes('passed') ? 'calibrated'
|
|
120
|
+
: verdicts.length > 0 && verdicts.every(v => v === 'not-supported') ? 'not-supported'
|
|
121
|
+
: 'failed';
|
|
122
|
+
const payload = { featureResults, diagnostics };
|
|
123
|
+
return await this.persistResult({
|
|
124
|
+
state: aggregate,
|
|
125
|
+
recordedAtIso,
|
|
126
|
+
notes: JSON.stringify(payload),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
console.error(`[SunspecBatteryFeatureCalibrator ${this.applianceId}] Unexpected error: ${error}`);
|
|
131
|
+
return await this.persistResult({
|
|
132
|
+
state: 'failed',
|
|
133
|
+
recordedAtIso,
|
|
134
|
+
notes: `Unexpected error: ${error instanceof Error ? error.message : String(error)}`,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
if (snapshotStarted) {
|
|
139
|
+
try {
|
|
140
|
+
await this.sunspecDriver.exitCalibrationMode();
|
|
141
|
+
}
|
|
142
|
+
catch (e) {
|
|
143
|
+
console.warn(`[SunspecBatteryFeatureCalibrator ${this.applianceId}] exitCalibrationMode failed: ${e}`);
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
await this.probeSnapshotService.stopCalibration();
|
|
147
|
+
}
|
|
148
|
+
catch (e) {
|
|
149
|
+
console.warn(`[SunspecBatteryFeatureCalibrator ${this.applianceId}] snapshotService.stopCalibration failed: ${e}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
this.isProbeRunning = false;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// ---------------------------------------------------------------------
|
|
156
|
+
// Probes
|
|
157
|
+
// ---------------------------------------------------------------------
|
|
158
|
+
async probeGridCharge(soc, snapshot) {
|
|
159
|
+
if (soc !== undefined && soc >= CHARGE_PROBE_MAX_SOC_PCT) {
|
|
160
|
+
return { verdict: 'not-supported', note: `SoC too high for charge probe (${soc}%)` };
|
|
161
|
+
}
|
|
162
|
+
const baselineBatW = this.sunspecDriver.readBatteryPowerW() ?? 0;
|
|
163
|
+
const baselineGridW = this.sunspecDriver.readGridPowerW() ?? 0;
|
|
164
|
+
const wrote = await this.sunspecClient.writeBatteryControls(this.unitId, {
|
|
165
|
+
chaGriSet: sunspec_interfaces_js_1.SunspecChargeSource.GRID,
|
|
166
|
+
wChaMax: this.probeConfig.testPowerW,
|
|
167
|
+
inWRte: 100,
|
|
168
|
+
storCtlMod: sunspec_interfaces_js_1.SunspecStorageControlMode.CHARGE,
|
|
169
|
+
});
|
|
170
|
+
if (!wrote) {
|
|
171
|
+
return { verdict: 'failed', note: 'writeBatteryControls returned false for grid charge' };
|
|
172
|
+
}
|
|
173
|
+
const ok = await this.waitForCondition((batW, gridW) => batW - baselineBatW > this.probeConfig.responseThresholdW
|
|
174
|
+
&& gridW - baselineGridW > this.probeConfig.responseThresholdW);
|
|
175
|
+
await this.resetToNeutral(snapshot);
|
|
176
|
+
if (!ok) {
|
|
177
|
+
return { verdict: 'failed', note: 'battery and/or grid power did not rise within timeout' };
|
|
178
|
+
}
|
|
179
|
+
return { verdict: 'passed', note: `battery rose ≥${this.probeConfig.responseThresholdW}W with grid supplying` };
|
|
180
|
+
}
|
|
181
|
+
async probeChargeLimitation(soc, snapshot) {
|
|
182
|
+
if (soc !== undefined && soc >= CHARGE_PROBE_MAX_SOC_PCT) {
|
|
183
|
+
return { verdict: 'not-supported', note: `SoC too high for charge limit probe (${soc}%)` };
|
|
184
|
+
}
|
|
185
|
+
const baselineBatW = this.sunspecDriver.readBatteryPowerW() ?? 0;
|
|
186
|
+
const wrote = await this.sunspecClient.writeBatteryControls(this.unitId, {
|
|
187
|
+
chaGriSet: sunspec_interfaces_js_1.SunspecChargeSource.GRID,
|
|
188
|
+
wChaMax: this.probeConfig.testPowerW,
|
|
189
|
+
inWRte: LIMIT_PROBE_RATE_PERCENT,
|
|
190
|
+
storCtlMod: sunspec_interfaces_js_1.SunspecStorageControlMode.CHARGE,
|
|
191
|
+
});
|
|
192
|
+
if (!wrote) {
|
|
193
|
+
return { verdict: 'failed', note: 'writeBatteryControls returned false for charge limit' };
|
|
194
|
+
}
|
|
195
|
+
const expectedW = this.probeConfig.testPowerW * (LIMIT_PROBE_RATE_PERCENT / 100);
|
|
196
|
+
const tolerance = this.probeConfig.responseThresholdW;
|
|
197
|
+
const ok = await this.waitForCondition((batW) => {
|
|
198
|
+
const observed = batW - baselineBatW;
|
|
199
|
+
return Math.abs(observed - expectedW) < tolerance;
|
|
200
|
+
});
|
|
201
|
+
await this.resetToNeutral(snapshot);
|
|
202
|
+
if (!ok) {
|
|
203
|
+
return { verdict: 'failed', note: `charge limit ignored — expected ~${expectedW}W, observed never settled within ±${tolerance}W` };
|
|
204
|
+
}
|
|
205
|
+
return { verdict: 'passed', note: `charge held within ±${tolerance}W of ${expectedW}W (50% of testPowerW)` };
|
|
206
|
+
}
|
|
207
|
+
async probeDischargeLimitation(soc, snapshot) {
|
|
208
|
+
if (soc !== undefined && soc <= DISCHARGE_PROBE_MIN_SOC_PCT) {
|
|
209
|
+
return { verdict: 'not-supported', note: `SoC too low for discharge limit probe (${soc}%)` };
|
|
210
|
+
}
|
|
211
|
+
const baselineBatW = this.sunspecDriver.readBatteryPowerW() ?? 0;
|
|
212
|
+
const wrote = await this.sunspecClient.writeBatteryControls(this.unitId, {
|
|
213
|
+
chaGriSet: sunspec_interfaces_js_1.SunspecChargeSource.PV,
|
|
214
|
+
wChaMax: this.probeConfig.testPowerW,
|
|
215
|
+
outWRte: LIMIT_PROBE_RATE_PERCENT,
|
|
216
|
+
storCtlMod: sunspec_interfaces_js_1.SunspecStorageControlMode.DISCHARGE,
|
|
217
|
+
});
|
|
218
|
+
if (!wrote) {
|
|
219
|
+
return { verdict: 'failed', note: 'writeBatteryControls returned false for discharge limit' };
|
|
220
|
+
}
|
|
221
|
+
const expectedW = this.probeConfig.testPowerW * (LIMIT_PROBE_RATE_PERCENT / 100);
|
|
222
|
+
const tolerance = this.probeConfig.responseThresholdW;
|
|
223
|
+
const ok = await this.waitForCondition((batW) => {
|
|
224
|
+
const dischargeW = baselineBatW - batW; // discharge → batW drops → dischargeW positive
|
|
225
|
+
return Math.abs(dischargeW - expectedW) < tolerance;
|
|
226
|
+
});
|
|
227
|
+
await this.resetToNeutral(snapshot);
|
|
228
|
+
if (!ok) {
|
|
229
|
+
return { verdict: 'failed', note: `discharge limit ignored — expected ~${expectedW}W, observed never settled within ±${tolerance}W` };
|
|
230
|
+
}
|
|
231
|
+
return { verdict: 'passed', note: `discharge held within ±${tolerance}W of ${expectedW}W (50% of testPowerW)` };
|
|
232
|
+
}
|
|
233
|
+
async probeGridDischarge(soc, snapshot) {
|
|
234
|
+
if (soc !== undefined && soc <= DISCHARGE_PROBE_MIN_SOC_PCT) {
|
|
235
|
+
return { verdict: 'not-supported', note: `SoC too low for grid discharge probe (${soc}%)` };
|
|
236
|
+
}
|
|
237
|
+
const baselineBatW = this.sunspecDriver.readBatteryPowerW() ?? 0;
|
|
238
|
+
const baselineGridW = this.sunspecDriver.readGridPowerW() ?? 0;
|
|
239
|
+
const wrote = await this.sunspecClient.writeBatteryControls(this.unitId, {
|
|
240
|
+
chaGriSet: sunspec_interfaces_js_1.SunspecChargeSource.PV,
|
|
241
|
+
wChaMax: this.probeConfig.testPowerW,
|
|
242
|
+
outWRte: 100,
|
|
243
|
+
storCtlMod: sunspec_interfaces_js_1.SunspecStorageControlMode.DISCHARGE,
|
|
244
|
+
});
|
|
245
|
+
if (!wrote) {
|
|
246
|
+
return { verdict: 'failed', note: 'writeBatteryControls returned false for grid discharge' };
|
|
247
|
+
}
|
|
248
|
+
const dischargeOk = await this.waitForCondition((batW) => baselineBatW - batW > this.probeConfig.responseThresholdW);
|
|
249
|
+
if (!dischargeOk) {
|
|
250
|
+
await this.resetToNeutral(snapshot);
|
|
251
|
+
return { verdict: 'failed', note: 'battery did not discharge within timeout' };
|
|
252
|
+
}
|
|
253
|
+
// Battery is now discharging. Check whether the meter shows reverse flow (export).
|
|
254
|
+
const exportOk = await this.waitForCondition((_batW, gridW) => baselineGridW - gridW > this.probeConfig.responseThresholdW);
|
|
255
|
+
await this.resetToNeutral(snapshot);
|
|
256
|
+
if (!exportOk) {
|
|
257
|
+
return { verdict: 'failed', note: 'discharge observed but no grid export — local load may have consumed it' };
|
|
258
|
+
}
|
|
259
|
+
return { verdict: 'passed', note: `discharge produced ≥${this.probeConfig.responseThresholdW}W of grid export` };
|
|
260
|
+
}
|
|
261
|
+
// ---------------------------------------------------------------------
|
|
262
|
+
// Helpers
|
|
263
|
+
// ---------------------------------------------------------------------
|
|
264
|
+
/**
|
|
265
|
+
* Reset to a neutral state between probes — chaGriSet=PV, storCtlMod=AUTO,
|
|
266
|
+
* rate limits cleared, wChaMax back to the captured baseline. The final
|
|
267
|
+
* restore via `snapshotService.stopCalibration` still runs and is what
|
|
268
|
+
* brings the device back to its pre-calibration register set.
|
|
269
|
+
*/
|
|
270
|
+
async resetToNeutral(snapshot) {
|
|
271
|
+
try {
|
|
272
|
+
await this.sunspecClient.writeBatteryControls(this.unitId, {
|
|
273
|
+
storCtlMod: sunspec_interfaces_js_1.SunspecStorageControlMode.CHARGE | sunspec_interfaces_js_1.SunspecStorageControlMode.DISCHARGE,
|
|
274
|
+
chaGriSet: sunspec_interfaces_js_1.SunspecChargeSource.PV,
|
|
275
|
+
wChaMax: snapshot.wChaMax,
|
|
276
|
+
inWRte: 100,
|
|
277
|
+
outWRte: 100,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
catch (e) {
|
|
281
|
+
console.warn(`[SunspecBatteryFeatureCalibrator ${this.applianceId}] resetToNeutral failed: ${e}`);
|
|
282
|
+
}
|
|
283
|
+
// Give the battery a moment to settle before the next probe samples baseline.
|
|
284
|
+
await this.probeClock.sleep(this.probeConfig.pollIntervalMs);
|
|
285
|
+
}
|
|
286
|
+
async waitForCondition(predicate) {
|
|
287
|
+
const deadline = this.probeClock.now() + this.probeConfig.responseTimeoutMs;
|
|
288
|
+
while (this.probeClock.now() < deadline) {
|
|
289
|
+
const batW = this.sunspecDriver.readBatteryPowerW() ?? 0;
|
|
290
|
+
const gridW = this.sunspecDriver.readGridPowerW() ?? 0;
|
|
291
|
+
if (predicate(batW, gridW))
|
|
292
|
+
return true;
|
|
293
|
+
await this.probeClock.sleep(this.probeConfig.pollIntervalMs);
|
|
294
|
+
}
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
async persistResult(result) {
|
|
298
|
+
await this.probeResultStore.save(this.applianceId, result);
|
|
299
|
+
return result;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
exports.SunspecBatteryFeatureCalibrator = SunspecBatteryFeatureCalibrator;
|
|
303
|
+
/**
|
|
304
|
+
* Decode the per-feature payload that the orchestrator JSON-encodes into
|
|
305
|
+
* `CalibrationResult.notes`. Returns the set of controllable features whose
|
|
306
|
+
* verdict is `passed`. Robust to:
|
|
307
|
+
*
|
|
308
|
+
* - `notes === undefined` (no calibration recorded yet)
|
|
309
|
+
* - non-JSON `notes` (legacy results from before this scheme)
|
|
310
|
+
* - missing `featureResults` field
|
|
311
|
+
* - unknown feature keys
|
|
312
|
+
* - bogus verdict strings
|
|
313
|
+
*
|
|
314
|
+
* No `as` casts at the use site — the JSON parse boundary uses `unknown` and
|
|
315
|
+
* narrows via `typeof` / `Object.entries`. Each candidate key is compared
|
|
316
|
+
* against the typed `SUNSPEC_CONTROLLABLE_FEATURES` whitelist so unknown keys
|
|
317
|
+
* are dropped without any `Record<string, unknown>` indirection. Matches the
|
|
318
|
+
* CLAUDE.md "JSON-parsing interop" exception rules.
|
|
319
|
+
*/
|
|
320
|
+
function decodeFeatureResults(notes) {
|
|
321
|
+
const passed = new Set();
|
|
322
|
+
if (!notes)
|
|
323
|
+
return passed;
|
|
324
|
+
let parsed;
|
|
325
|
+
try {
|
|
326
|
+
parsed = JSON.parse(notes);
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
return passed;
|
|
330
|
+
}
|
|
331
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
332
|
+
return passed;
|
|
333
|
+
let featureResults;
|
|
334
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
335
|
+
if (k === 'featureResults') {
|
|
336
|
+
featureResults = v;
|
|
337
|
+
break;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
if (typeof featureResults !== 'object' || featureResults === null)
|
|
341
|
+
return passed;
|
|
342
|
+
for (const [key, value] of Object.entries(featureResults)) {
|
|
343
|
+
if (value !== 'passed')
|
|
344
|
+
continue;
|
|
345
|
+
const match = sunspec_interfaces_js_1.SUNSPEC_CONTROLLABLE_FEATURES.find(f => f === key);
|
|
346
|
+
if (match)
|
|
347
|
+
passed.add(match);
|
|
348
|
+
}
|
|
349
|
+
return passed;
|
|
350
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { BatteryCalibrator, type BatteryCalibratorOptions, type CalibrationResult } from "@enyo-energy/appliance-calibration";
|
|
2
|
+
import { EnyoBatteryFeature } from "@enyo-energy/energy-app-sdk/dist/types/enyo-battery-appliance.js";
|
|
3
|
+
import { BatteryControlsClient } from "./sunspec-battery-schedule-handler.cjs";
|
|
4
|
+
import { type SunspecBatteryControls, type SunspecBatteryData } from "./sunspec-interfaces.cjs";
|
|
5
|
+
export interface SunspecBatteryFeatureCalibratorOptions extends BatteryCalibratorOptions<SunspecBatteryControls> {
|
|
6
|
+
sunspecClient: BatteryControlsClient;
|
|
7
|
+
unitId: number;
|
|
8
|
+
/** Pulls the latest battery data so the orchestrator can pre-check SoC. */
|
|
9
|
+
readBatteryData: () => Promise<SunspecBatteryData | null>;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* SunSpec-specific calibrator that exercises each of the four controllable
|
|
13
|
+
* battery features individually and records per-feature outcomes in the
|
|
14
|
+
* library's existing `CalibrationResult.notes` field (JSON-encoded).
|
|
15
|
+
*
|
|
16
|
+
* Sequence per session:
|
|
17
|
+
*
|
|
18
|
+
* 1. captureSnapshot — read writable Model 124 registers.
|
|
19
|
+
* 2. snapshotService.startCalibration — arms auto-restore.
|
|
20
|
+
* 3. enterCalibrationMode — driver no-op for SunSpec (returns "accepted").
|
|
21
|
+
* 4. probeGridCharge → battery power AND grid power rise above threshold.
|
|
22
|
+
* 5. probeChargeLimitation → inWRte=50, observe charge power ≈ testPowerW/2.
|
|
23
|
+
* 6. probeDischargeLimitation → outWRte=50, observe discharge power ≈ testPowerW/2.
|
|
24
|
+
* 7. probeGridDischarge → storCtlMod=DISCHARGE, observe meter export.
|
|
25
|
+
* 8. exitCalibrationMode + snapshotService.stopCalibration → restoreFromSnapshot fires.
|
|
26
|
+
*
|
|
27
|
+
* Charge probes run before discharge probes so the battery has energy to
|
|
28
|
+
* discharge with. Each probe ends with the registers reset to a neutral
|
|
29
|
+
* state (storCtlMod=AUTO, chaGriSet=PV, inWRte/outWRte=100, wChaMax=snapshot)
|
|
30
|
+
* so the next probe starts cleanly even though the snapshot service's
|
|
31
|
+
* restore only fires at the very end.
|
|
32
|
+
*
|
|
33
|
+
* The aggregated `CalibrationResult.state` is:
|
|
34
|
+
*
|
|
35
|
+
* - `calibrated` if at least one probe passed,
|
|
36
|
+
* - `not-supported` if every probe was `not-supported` (e.g. SoC out of band
|
|
37
|
+
* for both charge AND discharge),
|
|
38
|
+
* - `failed` otherwise.
|
|
39
|
+
*
|
|
40
|
+
* `isCalibrated(applianceId)` therefore reads as "the SDK probed this device
|
|
41
|
+
* and at least something is controllable" — keep using it as the broad
|
|
42
|
+
* command-emission gate. The per-feature breakdown in `notes` is what
|
|
43
|
+
* `SunspecBattery.resolveAdvertisedFeatures` consults to decide which
|
|
44
|
+
* controllable features to publish.
|
|
45
|
+
*/
|
|
46
|
+
export declare class SunspecBatteryFeatureCalibrator extends BatteryCalibrator<SunspecBatteryControls> {
|
|
47
|
+
private readonly sunspecDriver;
|
|
48
|
+
private readonly sunspecClient;
|
|
49
|
+
private readonly unitId;
|
|
50
|
+
private readonly readBatteryData;
|
|
51
|
+
private readonly probeConfig;
|
|
52
|
+
private readonly probeClock;
|
|
53
|
+
private readonly probeSnapshotService;
|
|
54
|
+
private readonly probeResultStore;
|
|
55
|
+
private isProbeRunning;
|
|
56
|
+
constructor(options: SunspecBatteryFeatureCalibratorOptions);
|
|
57
|
+
runCalibration(): Promise<CalibrationResult>;
|
|
58
|
+
private probeGridCharge;
|
|
59
|
+
private probeChargeLimitation;
|
|
60
|
+
private probeDischargeLimitation;
|
|
61
|
+
private probeGridDischarge;
|
|
62
|
+
/**
|
|
63
|
+
* Reset to a neutral state between probes — chaGriSet=PV, storCtlMod=AUTO,
|
|
64
|
+
* rate limits cleared, wChaMax back to the captured baseline. The final
|
|
65
|
+
* restore via `snapshotService.stopCalibration` still runs and is what
|
|
66
|
+
* brings the device back to its pre-calibration register set.
|
|
67
|
+
*/
|
|
68
|
+
private resetToNeutral;
|
|
69
|
+
private waitForCondition;
|
|
70
|
+
private persistResult;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Decode the per-feature payload that the orchestrator JSON-encodes into
|
|
74
|
+
* `CalibrationResult.notes`. Returns the set of controllable features whose
|
|
75
|
+
* verdict is `passed`. Robust to:
|
|
76
|
+
*
|
|
77
|
+
* - `notes === undefined` (no calibration recorded yet)
|
|
78
|
+
* - non-JSON `notes` (legacy results from before this scheme)
|
|
79
|
+
* - missing `featureResults` field
|
|
80
|
+
* - unknown feature keys
|
|
81
|
+
* - bogus verdict strings
|
|
82
|
+
*
|
|
83
|
+
* No `as` casts at the use site — the JSON parse boundary uses `unknown` and
|
|
84
|
+
* narrows via `typeof` / `Object.entries`. Each candidate key is compared
|
|
85
|
+
* against the typed `SUNSPEC_CONTROLLABLE_FEATURES` whitelist so unknown keys
|
|
86
|
+
* are dropped without any `Record<string, unknown>` indirection. Matches the
|
|
87
|
+
* CLAUDE.md "JSON-parsing interop" exception rules.
|
|
88
|
+
*/
|
|
89
|
+
export declare function decodeFeatureResults(notes: string | undefined): Set<EnyoBatteryFeature>;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SunspecBatteryScheduleHandler = void 0;
|
|
4
|
+
const storage_schedule_handler_js_1 = require("@enyo-energy/energy-app-sdk/dist/implementations/storage/storage-schedule-handler.js");
|
|
5
|
+
const enyo_data_bus_value_js_1 = require("@enyo-energy/energy-app-sdk/dist/types/enyo-data-bus-value.js");
|
|
6
|
+
const sunspec_interfaces_js_1 = require("./sunspec-interfaces.cjs");
|
|
7
|
+
/**
|
|
8
|
+
* Concrete `StorageScheduleHandler` for SunSpec Model 124 batteries. The base
|
|
9
|
+
* class owns the data-bus subscription, the 1-second tick, schedule
|
|
10
|
+
* validation, restart-safe persistence, and per-call serialization; this
|
|
11
|
+
* subclass only supplies the three lifecycle hooks plus an `onError`
|
|
12
|
+
* observer.
|
|
13
|
+
*/
|
|
14
|
+
class SunspecBatteryScheduleHandler extends storage_schedule_handler_js_1.StorageScheduleHandler {
|
|
15
|
+
sunspecClient;
|
|
16
|
+
unitId;
|
|
17
|
+
applianceIdForLog;
|
|
18
|
+
getSnapshotService;
|
|
19
|
+
onErrorCallback;
|
|
20
|
+
installedWChaMax;
|
|
21
|
+
constructor(opts) {
|
|
22
|
+
super(opts);
|
|
23
|
+
this.sunspecClient = opts.sunspecClient;
|
|
24
|
+
this.unitId = opts.unitId;
|
|
25
|
+
this.applianceIdForLog = opts.applianceId;
|
|
26
|
+
this.getSnapshotService = opts.getSnapshotService;
|
|
27
|
+
this.onErrorCallback = opts.onErrorCallback;
|
|
28
|
+
}
|
|
29
|
+
async onInit(_active) {
|
|
30
|
+
const controls = await this.sunspecClient.readBatteryControls(this.unitId);
|
|
31
|
+
if (!controls) {
|
|
32
|
+
throw new Error(`SunspecBatteryScheduleHandler ${this.applianceIdForLog}: failed to read pre-schedule controls`);
|
|
33
|
+
}
|
|
34
|
+
this.installedWChaMax = controls.wChaMax;
|
|
35
|
+
return {
|
|
36
|
+
storCtlMod: controls.storCtlMod,
|
|
37
|
+
chaGriSet: controls.chaGriSet,
|
|
38
|
+
wChaMax: controls.wChaMax,
|
|
39
|
+
inWRte: controls.inWRte,
|
|
40
|
+
outWRte: controls.outWRte,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
onChange(active, _previous) {
|
|
44
|
+
void this.applyEntry(active).catch(err => {
|
|
45
|
+
console.error(`SunspecBatteryScheduleHandler ${this.applianceIdForLog}: onChange write failed for entry ${active.indexInSchedule}: ${err}`);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
onRollback(registers) {
|
|
49
|
+
void (async () => {
|
|
50
|
+
try {
|
|
51
|
+
await this.sunspecClient.writeBatteryControls(this.unitId, registers);
|
|
52
|
+
await this.getSnapshotService()?.recordModification([
|
|
53
|
+
'storCtlMod', 'chaGriSet', 'wChaMax', 'inWRte', 'outWRte',
|
|
54
|
+
]);
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
console.error(`SunspecBatteryScheduleHandler ${this.applianceIdForLog}: onRollback failed: ${err}`);
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
this.installedWChaMax = undefined;
|
|
61
|
+
}
|
|
62
|
+
})();
|
|
63
|
+
}
|
|
64
|
+
onError(err) {
|
|
65
|
+
this.onErrorCallback?.(err);
|
|
66
|
+
}
|
|
67
|
+
async applyEntry(active) {
|
|
68
|
+
const { direction, powerW } = active.entry;
|
|
69
|
+
const baseline = this.installedWChaMax;
|
|
70
|
+
if (!baseline || baseline <= 0) {
|
|
71
|
+
throw new Error(`no usable wChaMax baseline (installedWChaMax=${baseline})`);
|
|
72
|
+
}
|
|
73
|
+
const pct = Math.min(100, Math.max(0, (powerW / baseline) * 100));
|
|
74
|
+
if (direction === enyo_data_bus_value_js_1.EnyoStorageScheduleDirectionEnum.Charge) {
|
|
75
|
+
await this.sunspecClient.writeBatteryControls(this.unitId, {
|
|
76
|
+
chaGriSet: sunspec_interfaces_js_1.SunspecChargeSource.GRID,
|
|
77
|
+
inWRte: pct,
|
|
78
|
+
storCtlMod: sunspec_interfaces_js_1.SunspecStorageControlMode.CHARGE,
|
|
79
|
+
});
|
|
80
|
+
await this.getSnapshotService()?.recordModification(['chaGriSet', 'inWRte', 'storCtlMod']);
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
await this.sunspecClient.writeBatteryControls(this.unitId, {
|
|
84
|
+
chaGriSet: sunspec_interfaces_js_1.SunspecChargeSource.PV,
|
|
85
|
+
outWRte: pct,
|
|
86
|
+
storCtlMod: sunspec_interfaces_js_1.SunspecStorageControlMode.DISCHARGE,
|
|
87
|
+
});
|
|
88
|
+
await this.getSnapshotService()?.recordModification(['chaGriSet', 'outWRte', 'storCtlMod']);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
exports.SunspecBatteryScheduleHandler = SunspecBatteryScheduleHandler;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { StorageScheduleHandler, type ActiveStorageScheduleEntry, type StorageScheduleHandlerOptions } from "@enyo-energy/energy-app-sdk/dist/implementations/storage/storage-schedule-handler.js";
|
|
2
|
+
import { type SnapshotService } from "@enyo-energy/appliance-calibration";
|
|
3
|
+
import { type SunspecBatteryControls } from "./sunspec-interfaces.cjs";
|
|
4
|
+
/**
|
|
5
|
+
* Narrow interface covering only the modbus reads/writes the schedule handler
|
|
6
|
+
* needs. The full `SunspecModbusClient` structurally satisfies this; pinning
|
|
7
|
+
* it down here keeps the handler decoupled from the rest of the client
|
|
8
|
+
* surface and makes the unit tests trivially stubbable without erasing types.
|
|
9
|
+
*/
|
|
10
|
+
export interface BatteryControlsClient {
|
|
11
|
+
readBatteryControls(unitId: number): Promise<SunspecBatteryControls | null>;
|
|
12
|
+
writeBatteryControls(unitId: number, controls: Partial<SunspecBatteryControls>): Promise<boolean>;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Narrow interface for the only `SnapshotService` method the schedule handler
|
|
16
|
+
* calls — `recordModification`. A real `SnapshotService<SunspecBatteryControls>`
|
|
17
|
+
* satisfies this structurally.
|
|
18
|
+
*/
|
|
19
|
+
export type BatteryModificationRecorder = Pick<SnapshotService<SunspecBatteryControls>, 'recordModification'>;
|
|
20
|
+
/**
|
|
21
|
+
* Snapshot of the writable Model 124 registers taken at schedule install time
|
|
22
|
+
* and handed back to `onRollback` when the schedule completes, is replaced, or
|
|
23
|
+
* is restored after a process restart. JSON-serialisable because
|
|
24
|
+
* `EnergyAppStorage` round-trips it.
|
|
25
|
+
*
|
|
26
|
+
* The snapshotted `wChaMax` doubles as the denominator for the `inWRte` /
|
|
27
|
+
* `outWRte` percentage math inside `onChange` — we never overwrite the live
|
|
28
|
+
* register during a schedule, so the baseline stays stable across entry
|
|
29
|
+
* transitions. Consequently scheduled `powerW` is capped at the installed
|
|
30
|
+
* `wChaMax`, matching the legacy setChargeLimit / setDischargeLimit semantics.
|
|
31
|
+
*/
|
|
32
|
+
export interface SunspecScheduleRegisters {
|
|
33
|
+
storCtlMod?: number;
|
|
34
|
+
chaGriSet?: number;
|
|
35
|
+
wChaMax?: number;
|
|
36
|
+
inWRte?: number;
|
|
37
|
+
outWRte?: number;
|
|
38
|
+
}
|
|
39
|
+
export interface SunspecBatteryScheduleHandlerOptions extends StorageScheduleHandlerOptions {
|
|
40
|
+
sunspecClient: BatteryControlsClient;
|
|
41
|
+
unitId: number;
|
|
42
|
+
applianceId: string;
|
|
43
|
+
/** Lazy so the snapshot service can be initialised after the handler. */
|
|
44
|
+
getSnapshotService: () => BatteryModificationRecorder | undefined;
|
|
45
|
+
onErrorCallback?: (err: Error) => void;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Concrete `StorageScheduleHandler` for SunSpec Model 124 batteries. The base
|
|
49
|
+
* class owns the data-bus subscription, the 1-second tick, schedule
|
|
50
|
+
* validation, restart-safe persistence, and per-call serialization; this
|
|
51
|
+
* subclass only supplies the three lifecycle hooks plus an `onError`
|
|
52
|
+
* observer.
|
|
53
|
+
*/
|
|
54
|
+
export declare class SunspecBatteryScheduleHandler extends StorageScheduleHandler<SunspecScheduleRegisters> {
|
|
55
|
+
private readonly sunspecClient;
|
|
56
|
+
private readonly unitId;
|
|
57
|
+
private readonly applianceIdForLog;
|
|
58
|
+
private readonly getSnapshotService;
|
|
59
|
+
private readonly onErrorCallback?;
|
|
60
|
+
private installedWChaMax;
|
|
61
|
+
constructor(opts: SunspecBatteryScheduleHandlerOptions);
|
|
62
|
+
protected onInit(_active: ActiveStorageScheduleEntry): Promise<SunspecScheduleRegisters>;
|
|
63
|
+
protected onChange(active: ActiveStorageScheduleEntry, _previous: ActiveStorageScheduleEntry | undefined): void;
|
|
64
|
+
protected onRollback(registers: SunspecScheduleRegisters): void;
|
|
65
|
+
protected onError(err: Error): void;
|
|
66
|
+
private applyEntry;
|
|
67
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SunspecCalibrationStorage = void 0;
|
|
4
|
+
exports.createSunspecCalibrationStorage = createSunspecCalibrationStorage;
|
|
5
|
+
const appliance_calibration_1 = require("@enyo-energy/appliance-calibration");
|
|
6
|
+
/**
|
|
7
|
+
* Adapter that bridges the energy-app SDK's `EnergyAppStorage` to the
|
|
8
|
+
* `AbstractCalibrationStorage` seam expected by `@enyo-energy/appliance-calibration`.
|
|
9
|
+
*
|
|
10
|
+
* The host SDK's `load()` returns `null` for missing keys; the calibration
|
|
11
|
+
* library contract requires `undefined`. This adapter normalises that single
|
|
12
|
+
* difference.
|
|
13
|
+
*/
|
|
14
|
+
class SunspecCalibrationStorage extends appliance_calibration_1.AbstractCalibrationStorage {
|
|
15
|
+
storage;
|
|
16
|
+
constructor(storage) {
|
|
17
|
+
super();
|
|
18
|
+
this.storage = storage;
|
|
19
|
+
}
|
|
20
|
+
async load(key) {
|
|
21
|
+
const value = await this.storage.load(key);
|
|
22
|
+
return value ?? undefined;
|
|
23
|
+
}
|
|
24
|
+
async save(key, value) {
|
|
25
|
+
// The host SDK's `save` only accepts `object`. Narrow via a runtime guard so the
|
|
26
|
+
// generic `T` reaches the call site as a checked `object` — no cast needed. The
|
|
27
|
+
// calibration library only ever persists struct-shaped state, so any non-object
|
|
28
|
+
// here is a programmer error.
|
|
29
|
+
const candidate = value;
|
|
30
|
+
if (typeof candidate !== "object" || candidate === null) {
|
|
31
|
+
throw new TypeError(`SunspecCalibrationStorage.save expects an object value, got ${candidate === null ? "null" : typeof candidate}`);
|
|
32
|
+
}
|
|
33
|
+
await this.storage.save(key, candidate);
|
|
34
|
+
}
|
|
35
|
+
async remove(key) {
|
|
36
|
+
await this.storage.remove(key);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
exports.SunspecCalibrationStorage = SunspecCalibrationStorage;
|
|
40
|
+
/**
|
|
41
|
+
* Construct a calibration storage adapter from the running `EnergyApp` instance.
|
|
42
|
+
* Consumers wire this once and pass the same instance into every appliance + the
|
|
43
|
+
* shared `CalibrationResultStore`.
|
|
44
|
+
*/
|
|
45
|
+
function createSunspecCalibrationStorage(app) {
|
|
46
|
+
return new SunspecCalibrationStorage(app.useStorage());
|
|
47
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { AbstractCalibrationStorage } from "@enyo-energy/appliance-calibration";
|
|
2
|
+
import { EnergyAppStorage } from "@enyo-energy/energy-app-sdk/dist/packages/energy-app-storage.js";
|
|
3
|
+
import type { EnergyApp } from "@enyo-energy/energy-app-sdk";
|
|
4
|
+
/**
|
|
5
|
+
* Adapter that bridges the energy-app SDK's `EnergyAppStorage` to the
|
|
6
|
+
* `AbstractCalibrationStorage` seam expected by `@enyo-energy/appliance-calibration`.
|
|
7
|
+
*
|
|
8
|
+
* The host SDK's `load()` returns `null` for missing keys; the calibration
|
|
9
|
+
* library contract requires `undefined`. This adapter normalises that single
|
|
10
|
+
* difference.
|
|
11
|
+
*/
|
|
12
|
+
export declare class SunspecCalibrationStorage extends AbstractCalibrationStorage {
|
|
13
|
+
private readonly storage;
|
|
14
|
+
constructor(storage: EnergyAppStorage);
|
|
15
|
+
load<T>(key: string): Promise<T | undefined>;
|
|
16
|
+
save<T>(key: string, value: T): Promise<void>;
|
|
17
|
+
remove(key: string): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Construct a calibration storage adapter from the running `EnergyApp` instance.
|
|
21
|
+
* Consumers wire this once and pass the same instance into every appliance + the
|
|
22
|
+
* shared `CalibrationResultStore`.
|
|
23
|
+
*/
|
|
24
|
+
export declare function createSunspecCalibrationStorage(app: EnergyApp): SunspecCalibrationStorage;
|