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