@enyo-energy/sunspec-sdk 0.0.71 → 0.0.73

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +302 -0
  2. package/dist/cjs/index.cjs +30 -2
  3. package/dist/cjs/index.d.cts +6 -1
  4. package/dist/cjs/sunspec-battery-calibration-driver.cjs +158 -0
  5. package/dist/cjs/sunspec-battery-calibration-driver.d.cts +63 -0
  6. package/dist/cjs/sunspec-battery-feature-calibrator.cjs +350 -0
  7. package/dist/cjs/sunspec-battery-feature-calibrator.d.cts +89 -0
  8. package/dist/cjs/sunspec-battery-schedule-handler.cjs +92 -0
  9. package/dist/cjs/sunspec-battery-schedule-handler.d.cts +67 -0
  10. package/dist/cjs/sunspec-calibration-storage.cjs +47 -0
  11. package/dist/cjs/sunspec-calibration-storage.d.cts +24 -0
  12. package/dist/cjs/sunspec-devices.cjs +305 -215
  13. package/dist/cjs/sunspec-devices.d.cts +129 -19
  14. package/dist/cjs/sunspec-interfaces.cjs +42 -1
  15. package/dist/cjs/sunspec-interfaces.d.cts +66 -0
  16. package/dist/cjs/version.cjs +1 -1
  17. package/dist/cjs/version.d.cts +1 -1
  18. package/dist/index.d.ts +6 -1
  19. package/dist/index.js +12 -1
  20. package/dist/sunspec-battery-calibration-driver.d.ts +63 -0
  21. package/dist/sunspec-battery-calibration-driver.js +154 -0
  22. package/dist/sunspec-battery-feature-calibrator.d.ts +89 -0
  23. package/dist/sunspec-battery-feature-calibrator.js +345 -0
  24. package/dist/sunspec-battery-schedule-handler.d.ts +67 -0
  25. package/dist/sunspec-battery-schedule-handler.js +88 -0
  26. package/dist/sunspec-calibration-storage.d.ts +24 -0
  27. package/dist/sunspec-calibration-storage.js +42 -0
  28. package/dist/sunspec-devices.d.ts +129 -19
  29. package/dist/sunspec-devices.js +304 -216
  30. package/dist/sunspec-interfaces.d.ts +66 -0
  31. package/dist/sunspec-interfaces.js +41 -0
  32. package/dist/version.d.ts +1 -1
  33. package/dist/version.js +1 -1
  34. package/package.json +7 -3
@@ -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;