@enyo-energy/sunspec-sdk 0.0.70 → 0.0.72
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +302 -0
- package/dist/calibration-snapshot-service.d.ts +67 -0
- package/dist/calibration-snapshot-service.js +160 -0
- package/dist/cjs/calibration-snapshot-service.cjs +164 -0
- package/dist/cjs/calibration-snapshot-service.d.cts +67 -0
- package/dist/cjs/index.cjs +30 -1
- package/dist/cjs/index.d.cts +6 -0
- 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 +407 -104
- package/dist/cjs/sunspec-devices.d.cts +112 -6
- 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 -0
- package/dist/index.js +12 -0
- 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 +112 -6
- package/dist/sunspec-devices.js +408 -105
- package/dist/sunspec-interfaces.d.ts +66 -0
- package/dist/sunspec-interfaces.js +41 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -5,6 +5,9 @@ SunSpec Modbus client for reading data from solar inverters, batteries, meters,
|
|
|
5
5
|
## Table of Contents
|
|
6
6
|
|
|
7
7
|
- [Appliance Manager Integration](#appliance-manager-integration)
|
|
8
|
+
- [Battery Feature Modes](#battery-feature-modes)
|
|
9
|
+
- [Battery Calibration](#battery-calibration)
|
|
10
|
+
- [Storage Schedule Control](#storage-schedule-control)
|
|
8
11
|
- [How Addressing Works](#how-addressing-works)
|
|
9
12
|
- [Bulk Register Reading](#bulk-register-reading)
|
|
10
13
|
- [Data Types](#data-types)
|
|
@@ -46,6 +49,305 @@ This SDK does not configure the strategy itself — it is the consumer app's res
|
|
|
46
49
|
|
|
47
50
|
---
|
|
48
51
|
|
|
52
|
+
## Battery Feature Modes
|
|
53
|
+
|
|
54
|
+
`SunspecBattery` populates `appliance.battery.features` for every connected battery — the list the host's topology / UI reads to decide which control surfaces to render. **The consumer chooses how that list is computed** by passing a required `featureMode` argument to the `SunspecBattery` constructor. There is no default; the choice is explicit so a SunSpec device whose register surface lies (or whose responsiveness is unknown) cannot silently turn into an actionable battery from the host's point of view.
|
|
55
|
+
|
|
56
|
+
Three kinds are available, exposed via the `SunspecBatteryFeatureModeKind` enum:
|
|
57
|
+
|
|
58
|
+
| Kind | Calibration allowed? | What appears in `appliance.battery.features` |
|
|
59
|
+
|---|---|---|
|
|
60
|
+
| `Disabled` | No (`configureCalibration` throws) | Exactly the `allowedFeatures` array the consumer passes — register state is ignored. |
|
|
61
|
+
| `RegisterBased` | No (`configureCalibration` throws) | Features the SunSpec registers expose. Filtered down to `allowedFeatures` if that's set. |
|
|
62
|
+
| `CalibrationBased` | Yes (required for controllable features to ever appear) | Same as `RegisterBased` for read-only features. The four controllable features stay hidden until the calibration result store reports the appliance as `calibrated`. |
|
|
63
|
+
|
|
64
|
+
The four features the SDK treats as **controllable** (the ones calibration gates):
|
|
65
|
+
|
|
66
|
+
- `EnyoBatteryFeature.GridCharging`
|
|
67
|
+
- `EnyoBatteryFeature.GridDischarging`
|
|
68
|
+
- `EnyoBatteryFeature.ChargeLimitation`
|
|
69
|
+
- `EnyoBatteryFeature.DischargeLimitation`
|
|
70
|
+
|
|
71
|
+
Every other `EnyoBatteryFeature` is **read-only** from the SDK's perspective and is published as soon as the corresponding register is present (in `RegisterBased` and `CalibrationBased`) or as soon as the consumer lists it (in `Disabled`).
|
|
72
|
+
|
|
73
|
+
### `Disabled`
|
|
74
|
+
|
|
75
|
+
Picks no registers, runs no calibration. Whatever the consumer lists in `allowedFeatures` becomes the published list; omit it (or pass `[]`) to publish nothing.
|
|
76
|
+
|
|
77
|
+
Use when the host has out-of-band knowledge of the battery's capabilities — e.g. a curated commissioning database — and doesn't want the SDK inferring anything from registers it may not trust. Also useful for read-only deployments: omit `allowedFeatures` and the host will treat the battery as observation-only.
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
import { SunspecBattery, SunspecBatteryFeatureModeKind } from '@enyo-energy/sunspec-sdk';
|
|
81
|
+
import { EnyoBatteryFeature } from '@enyo-energy/energy-app-sdk';
|
|
82
|
+
|
|
83
|
+
const observationOnly = new SunspecBattery(
|
|
84
|
+
app, name, networkDevice, client, applianceManager,
|
|
85
|
+
{ kind: SunspecBatteryFeatureModeKind.Disabled },
|
|
86
|
+
// → appliance.battery.features = []
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const curated = new SunspecBattery(
|
|
90
|
+
app, name, networkDevice, client, applianceManager,
|
|
91
|
+
{
|
|
92
|
+
kind: SunspecBatteryFeatureModeKind.Disabled,
|
|
93
|
+
allowedFeatures: [EnyoBatteryFeature.GridCharging],
|
|
94
|
+
},
|
|
95
|
+
// → appliance.battery.features = ['grid-charging'], regardless of what the registers say
|
|
96
|
+
);
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Calling `configureCalibration(...)` on a `Disabled` battery throws.
|
|
100
|
+
|
|
101
|
+
### `RegisterBased`
|
|
102
|
+
|
|
103
|
+
The SDK looks at the SunSpec Model 124 registers and publishes the features whose underlying registers are present:
|
|
104
|
+
|
|
105
|
+
- `chaGriSet` register present → `EnyoBatteryFeature.GridCharging`
|
|
106
|
+
- `wChaMax` register present → `EnyoBatteryFeature.ChargeLimitation`
|
|
107
|
+
|
|
108
|
+
Pass `allowedFeatures` to intersect the detected set with a whitelist — useful for hiding features the device exposes but the host doesn't want exercised. The allow-list **never** adds features that the registers don't expose; it is strictly a filter.
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
const trustRegisters = new SunspecBattery(
|
|
112
|
+
app, name, networkDevice, client, applianceManager,
|
|
113
|
+
{ kind: SunspecBatteryFeatureModeKind.RegisterBased },
|
|
114
|
+
// → appliance.battery.features reflects whichever Model 124 controls are exposed
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const trustRegistersButHideGridCharging = new SunspecBattery(
|
|
118
|
+
app, name, networkDevice, client, applianceManager,
|
|
119
|
+
{
|
|
120
|
+
kind: SunspecBatteryFeatureModeKind.RegisterBased,
|
|
121
|
+
allowedFeatures: [EnyoBatteryFeature.ChargeLimitation],
|
|
122
|
+
},
|
|
123
|
+
// → publishes ChargeLimitation only (even if chaGriSet is present)
|
|
124
|
+
);
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Calling `configureCalibration(...)` on a `RegisterBased` battery throws.
|
|
128
|
+
|
|
129
|
+
### `CalibrationBased`
|
|
130
|
+
|
|
131
|
+
Same register-driven detection as `RegisterBased` for read-only features, but the four controllable features stay stripped from the published list until `CalibrationResultStore.isCalibrated(applianceId)` returns `true` for this battery. Once the verdict flips, the next `readData()` cycle republishes the full set.
|
|
132
|
+
|
|
133
|
+
`configureCalibration(...)` must be called after `connect()` — without it, controllable features stay hidden forever (safe-fallback behaviour).
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
const verified = new SunspecBattery(
|
|
137
|
+
app, name, networkDevice, client, applianceManager,
|
|
138
|
+
{ kind: SunspecBatteryFeatureModeKind.CalibrationBased },
|
|
139
|
+
);
|
|
140
|
+
await verified.connect();
|
|
141
|
+
verified.configureCalibration({ resultStore }); // see "Battery Calibration" below
|
|
142
|
+
trigger.register(verified.getBatteryCalibrator()!);
|
|
143
|
+
// Until trigger.tick() runs a successful test charge, controllable features are absent.
|
|
144
|
+
// After it succeeds, they reappear within one readData() cycle.
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
See [Battery Calibration](#battery-calibration) for the full wiring of `resultStore`, `trigger`, and `configureCalibration` config.
|
|
148
|
+
|
|
149
|
+
### Choosing a mode
|
|
150
|
+
|
|
151
|
+
| Situation | Pick |
|
|
152
|
+
|---|---|
|
|
153
|
+
| Battery is observation-only, or the host already knows its capabilities | `Disabled` |
|
|
154
|
+
| Trust the SunSpec register surface; no responsiveness verification needed | `RegisterBased` |
|
|
155
|
+
| Want a test charge to confirm the battery actually reacts before exposing controls | `CalibrationBased` |
|
|
156
|
+
|
|
157
|
+
The mode is fixed for the lifetime of the `SunspecBattery` instance. To switch modes, construct a new instance.
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## Battery Calibration
|
|
162
|
+
|
|
163
|
+
The SDK integrates [`@enyo-energy/appliance-calibration`](https://www.npmjs.com/package/@enyo-energy/appliance-calibration) to verify that a battery actually responds to control commands before the host action-taker is allowed to send it any. The verification is a short test charge wrapped in a snapshot/restore so a hung or crashed run never leaves writable registers in a half-modified state, and the verdict (`calibrated` / `not-supported` / `failed`) is persisted per appliance so it survives restarts.
|
|
164
|
+
|
|
165
|
+
The SDK owns the SunSpec-specific pieces — the snapshot service, the vendor driver, the per-battery `BatteryCalibrator`. The **consumer owns the trigger and the result store**, so scheduling, multi-tenant storage namespacing, and command gating stay configurable.
|
|
166
|
+
|
|
167
|
+
### Architecture
|
|
168
|
+
|
|
169
|
+
| Piece | Lives in | Who builds it |
|
|
170
|
+
|---|---|---|
|
|
171
|
+
| `SunspecCalibrationStorage` (adapter over `EnergyAppStorage`) | this SDK | consumer (factory: `createSunspecCalibrationStorage(app)`) |
|
|
172
|
+
| `CalibrationResultStore` (persisted verdict map) | `appliance-calibration` | consumer — **one shared instance** across batteries |
|
|
173
|
+
| `SnapshotService<SunspecBatteryControls>` | `appliance-calibration` | SDK (per-battery, inside `SunspecBattery`) |
|
|
174
|
+
| `SunspecBatteryCalibrationDriver` | this SDK | SDK (per-battery, inside `SunspecBattery`) |
|
|
175
|
+
| `BatteryCalibrator<SunspecBatteryControls>` | `appliance-calibration` | SDK — built by `configureCalibration`, exposed via `getBatteryCalibrator` |
|
|
176
|
+
| `CalibrationTrigger` | `appliance-calibration` | consumer — drives `runCalibration()` on its own schedule |
|
|
177
|
+
|
|
178
|
+
### Feature mode
|
|
179
|
+
|
|
180
|
+
Calibration is only relevant when `SunspecBattery` is constructed with `SunspecBatteryFeatureModeKind.CalibrationBased` — that's the mode under which the SDK gates controllable features on the calibration verdict. See [Battery Feature Modes](#battery-feature-modes) above for the full mode discussion; the rest of this section assumes the calibration-based mode has been selected.
|
|
181
|
+
|
|
182
|
+
### Consumer wiring
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
import {
|
|
186
|
+
SunspecBattery,
|
|
187
|
+
SunspecBatteryFeatureModeKind,
|
|
188
|
+
// re-exported from @enyo-energy/appliance-calibration so you don't need a second import:
|
|
189
|
+
CalibrationResultStore,
|
|
190
|
+
CalibrationTrigger,
|
|
191
|
+
createSunspecCalibrationStorage,
|
|
192
|
+
} from '@enyo-energy/sunspec-sdk';
|
|
193
|
+
|
|
194
|
+
// 1. One storage adapter + one result store for the whole app — sharing the result store
|
|
195
|
+
// across batteries keeps the persisted verdict map from being clobbered by per-instance writes.
|
|
196
|
+
const storage = createSunspecCalibrationStorage(app);
|
|
197
|
+
const resultStore = new CalibrationResultStore(storage);
|
|
198
|
+
await resultStore.initialize();
|
|
199
|
+
|
|
200
|
+
// 2. One trigger that fans out across every registered calibrator.
|
|
201
|
+
const trigger = new CalibrationTrigger({ resultStore });
|
|
202
|
+
|
|
203
|
+
// 3. Per battery: build it with the desired feature mode, then connect,
|
|
204
|
+
// then (only in calibration-based mode) configureCalibration + register.
|
|
205
|
+
for (const config of batteryConfigs) {
|
|
206
|
+
const battery = new SunspecBattery(
|
|
207
|
+
app, name, networkDevice, client, applianceManager,
|
|
208
|
+
{ kind: SunspecBatteryFeatureModeKind.CalibrationBased },
|
|
209
|
+
// unitId, port, baseAddress, ...
|
|
210
|
+
);
|
|
211
|
+
await battery.connect();
|
|
212
|
+
battery.configureCalibration({
|
|
213
|
+
resultStore,
|
|
214
|
+
// Optional — override any BatteryCalibratorConfig defaults (see "Tuning" below).
|
|
215
|
+
config: { testPowerW: 300 },
|
|
216
|
+
});
|
|
217
|
+
const calibrator = battery.getBatteryCalibrator();
|
|
218
|
+
if (calibrator) {
|
|
219
|
+
trigger.register(calibrator);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// 4. Drive the trigger on your own cadence. tick() runs at most one calibration per call
|
|
224
|
+
// and is a no-op for any battery that already has a stored result.
|
|
225
|
+
client.useInterval().createInterval('30m', () => trigger.tick());
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
### Gate commands on the calibration result
|
|
229
|
+
|
|
230
|
+
In whatever code emits battery commands, check the store synchronously before sending:
|
|
231
|
+
|
|
232
|
+
```ts
|
|
233
|
+
if (!resultStore.isCalibrated(applianceId)) {
|
|
234
|
+
console.log(`Skipping ${applianceId}: not calibrated yet`);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
// ...send the command
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
### Per-feature probes
|
|
241
|
+
|
|
242
|
+
In `calibration-based` mode the SDK runs `SunspecBatteryFeatureCalibrator` (a subclass of the library's `BatteryCalibrator`) which **exercises each of the four controllable features individually** and records the outcomes in `CalibrationResult.notes` as JSON. Only features whose probes pass land in `appliance.battery.features` after calibration.
|
|
243
|
+
|
|
244
|
+
The session is one snapshot → four probes → one restore. Probes run in this order so the battery has energy to discharge with by the end:
|
|
245
|
+
|
|
246
|
+
| # | Probe | What it writes | Pass condition |
|
|
247
|
+
|---|---|---|---|
|
|
248
|
+
| 1 | `GridCharging` | `chaGriSet=GRID`, `wChaMax=testPowerW`, `inWRte=100`, `storCtlMod=CHARGE` | Battery power AND grid power both rise above `responseThresholdW` |
|
|
249
|
+
| 2 | `ChargeLimitation` | same as above with `inWRte=50` | Charge power settles within `±responseThresholdW` of `testPowerW × 0.5` |
|
|
250
|
+
| 3 | `DischargeLimitation` | `chaGriSet=PV`, `outWRte=50`, `storCtlMod=DISCHARGE` | Discharge power settles within `±responseThresholdW` of `testPowerW × 0.5` |
|
|
251
|
+
| 4 | `GridDischarging` | `chaGriSet=PV`, `outWRte=100`, `storCtlMod=DISCHARGE` | Battery discharges AND meter shows reverse flow above `responseThresholdW` |
|
|
252
|
+
|
|
253
|
+
Each probe runs a `writeBatteryControls(...)` then polls the driver's cached battery/grid power until either the predicate passes or `responseTimeoutMs` elapses. Between probes the SDK resets to a neutral state (`storCtlMod=AUTO`, `chaGriSet=PV`, `inWRte/outWRte=100`, `wChaMax=baseline`) so each probe starts cleanly. The final `restoreFromSnapshot` (run by the `SnapshotService` on `stopCalibration`) writes the original pre-calibration register set back regardless of outcome.
|
|
254
|
+
|
|
255
|
+
#### SoC preconditions
|
|
256
|
+
|
|
257
|
+
Probes refuse to run outside a useful SoC band and return `not-supported` instead — distinct from `failed` so the host can tell "device won't" apart from "we couldn't even try":
|
|
258
|
+
|
|
259
|
+
- Charge probes (`GridCharging`, `ChargeLimitation`) need headroom: SoC ≥ 90% → `not-supported`.
|
|
260
|
+
- Discharge probes (`DischargeLimitation`, `GridDischarging`) need energy: SoC ≤ 20% → `not-supported`.
|
|
261
|
+
|
|
262
|
+
#### Aggregate verdict
|
|
263
|
+
|
|
264
|
+
The library's `CalibrationResult.state` (one value per appliance) is computed from the four per-feature verdicts:
|
|
265
|
+
|
|
266
|
+
| Per-feature verdicts | `CalibrationResult.state` |
|
|
267
|
+
|---|---|
|
|
268
|
+
| At least one `passed` | `calibrated` |
|
|
269
|
+
| All `not-supported` | `not-supported` |
|
|
270
|
+
| Otherwise (mix of `failed` / `not-supported`) | `failed` |
|
|
271
|
+
|
|
272
|
+
`isCalibrated(applianceId)` therefore reads as **"the SDK probed this device and at least something is controllable"** — keep using it as the broad command-emission gate. The per-feature breakdown stored in `notes` is what `resolveAdvertisedFeatures` consults to decide *which* controllable features to publish; `decodeFeatureResults(result.notes)` returns the passing set and is exported for consumers that want to inspect it directly.
|
|
273
|
+
|
|
274
|
+
#### Other operational notes
|
|
275
|
+
|
|
276
|
+
- **Republishing cadence**: the controllable features reappear on the next `readData()` cycle after the calibrator persists its result — not synchronously when calibration completes. With a typical 10s/30s/1m poll cadence the appliance metadata follows within one cycle.
|
|
277
|
+
- **Defence in depth**: the feature filter is complementary to the [`isCalibrated` command-gate](#gate-commands-on-the-calibration-result), not a substitute. The consumer's action-taker should still check `resultStore.isCalibrated(applianceId)` synchronously before every command write — the filter keeps the controllable surface off the appliance until the device is verified, but a race window exists between calibration completing and the next readData cycle.
|
|
278
|
+
- **GridDischarge ambiguity**: SunSpec Model 124 has no register to *force* battery output toward the grid versus toward local load. The probe drives the battery into discharge and then watches the meter for export. If the host's local load eats the discharge there will be no export and the probe records `failed` with a note explaining the likely cause. Hosts that know their grid topology can map this to "controllable, but couldn't be verified".
|
|
279
|
+
|
|
280
|
+
### What `configureCalibration` does under the hood
|
|
281
|
+
|
|
282
|
+
For each battery it builds:
|
|
283
|
+
|
|
284
|
+
- A `SunspecBatteryCalibrationDriver` that:
|
|
285
|
+
- Snapshots writable controls via `readBatteryControls()`.
|
|
286
|
+
- Restores them via `writeBatteryControls()` (whitelist: `storCtlMod`, `chaGriSet`, `wChaMax`, `inWRte`, `outWRte`, `minRsvPct`).
|
|
287
|
+
- Runs the test charge with the same 3-step sequence as `handleStartGridCharge` (`enableGridCharging(true)`, `wChaMax = powerW`, `setStorageMode(CHARGE)`).
|
|
288
|
+
- Reads battery power from a `LatestValueCache` fed by `SunspecBattery.readData()` each cycle.
|
|
289
|
+
- Reads grid power from a `LatestValueCache` fed by a `MeterValuesUpdateV1` data-bus subscription that's live for the driver's lifetime (so the grid signal works regardless of where the meter actually comes from).
|
|
290
|
+
- A `BatteryCalibrator<SunspecBatteryControls>` wired to the driver, the per-battery `SnapshotService`, and the shared `resultStore`.
|
|
291
|
+
|
|
292
|
+
Battery `disconnect()` tears the driver subscription down.
|
|
293
|
+
|
|
294
|
+
### What still happens through the data bus
|
|
295
|
+
|
|
296
|
+
The SDK also keeps listening for `StartCalibrationV1` / `StopCalibrationV1` data-bus messages so an external orchestrator (e.g. the action-taker) can begin and end a manual calibration session. Those handlers now drive the same `SnapshotService<T>` instance from `@enyo-energy/appliance-calibration`. While a manual session is active, any concurrent `SetInverterFeedInLimitV1` / `SetStorageChargeLimitV1` / etc. call records the field it touched into `modifiedFields`, and only those fields are written back on stop (or on the 5-minute auto-stop). This works exactly as before — the change is purely the implementation underneath.
|
|
297
|
+
|
|
298
|
+
### Re-running a calibration
|
|
299
|
+
|
|
300
|
+
The trigger runs each battery **once** — appliances already marked `calibrated`, `not-supported`, or `failed` are skipped on every subsequent `tick()`. To force a re-run, drop the persisted result for that key:
|
|
301
|
+
|
|
302
|
+
```ts
|
|
303
|
+
await app.useStorage().remove('battery-calibration'); // wipes ALL results (the default key)
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
If you need targeted invalidation, snapshot the store via `resultStore.snapshot()`, mutate the map, and re-save the survivors. Periodic re-calibration is a host concern — build it on top of `calibrator.runCalibration()` directly rather than going through `CalibrationTrigger`.
|
|
307
|
+
|
|
308
|
+
### Tuning
|
|
309
|
+
|
|
310
|
+
Override defaults via `configureCalibration({ resultStore, config })`:
|
|
311
|
+
|
|
312
|
+
| Key | Default | When to change |
|
|
313
|
+
|---|---|---|
|
|
314
|
+
| `testPowerW` | 500 W | Smaller batteries; PV-only sites where 500 W is visible. |
|
|
315
|
+
| `responseThresholdW` | 200 W | Noisier meters; tighter tolerance for known-good devices. |
|
|
316
|
+
| `ackTimeoutMs` | 10 s | Slow Modbus links. |
|
|
317
|
+
| `responseTimeoutMs` | 20 s | Devices that take longer to ramp. |
|
|
318
|
+
| `pollIntervalMs` | 2 s | Faster polling for short-window tests. |
|
|
319
|
+
|
|
320
|
+
See [`@enyo-energy/appliance-calibration`'s README](https://www.npmjs.com/package/@enyo-energy/appliance-calibration) for the full crash-safety contract and every result-store / snapshot-service knob.
|
|
321
|
+
|
|
322
|
+
---
|
|
323
|
+
|
|
324
|
+
## Storage Schedule Control
|
|
325
|
+
|
|
326
|
+
`SunspecBattery` reacts to a single data-bus command for control: `EnyoDataBusSetStorageScheduleV1`. The message carries a `mode` (`auto` or `schedule`) and, when `mode` is `schedule`, a sorted `relativeSchedule` of `{seconds, direction, powerW}` entries. Subscription is automatic — each battery wires its own `SunspecBatteryScheduleHandler` (extending the SDK's `StorageScheduleHandler` from `@enyo-energy/energy-app-sdk`) during `connect()`. There is no consumer setup beyond connecting the battery.
|
|
327
|
+
|
|
328
|
+
### What the SDK does on receipt
|
|
329
|
+
|
|
330
|
+
1. Acks the message with `Accepted` immediately ("received & queued" — schedule entries play out over time).
|
|
331
|
+
2. Snapshots the writable Model 124 registers (`storCtlMod`, `chaGriSet`, `wChaMax`, `inWRte`, `outWRte`) and persists them to `EnergyAppStorage` for restart-safe rollback.
|
|
332
|
+
3. Activates the first entry immediately, then advances to subsequent entries as their `seconds` offsets elapse on a 1-second tick (driven by `EnergyApp.useInterval()`).
|
|
333
|
+
4. Each `Charge` entry writes `chaGriSet=GRID`, `inWRte = powerW / installedWChaMax × 100`, `storCtlMod=CHARGE`.
|
|
334
|
+
5. Each `Discharge` entry writes `chaGriSet=PV`, `outWRte = powerW / installedWChaMax × 100`, `storCtlMod=DISCHARGE`.
|
|
335
|
+
6. On `mode: auto`, a replacement schedule, `disconnect()`, or process restart with an active schedule still persisted, the SDK restores the snapshotted pre-schedule registers via `onRollback`.
|
|
336
|
+
|
|
337
|
+
### Power cap
|
|
338
|
+
|
|
339
|
+
Scheduled `powerW` is clamped to the device's installed `wChaMax` because the handler holds that value stable as the denominator for the percentage math throughout the schedule. To request more than the installed maximum, raise `wChaMax` out-of-band before sending the schedule.
|
|
340
|
+
|
|
341
|
+
### Calibration coexistence
|
|
342
|
+
|
|
343
|
+
Each per-entry write is recorded into the calibration `SnapshotService` (when calibration is configured), so a calibration that starts mid-schedule still restores the right register set on stop / auto-stop.
|
|
344
|
+
|
|
345
|
+
### Legacy messages
|
|
346
|
+
|
|
347
|
+
The earlier `StartStorageGridChargeV1` / `StopStorageGridChargeV1` / `SetStorageChargeLimitV1` / `SetStorageDischargeLimitV1` family is **no longer handled** by this SDK. Their type defs in `@enyo-energy/energy-app-sdk` are flagged `@deprecated` with pointers to `SetStorageScheduleV1`. Migrate any callers that still emit them.
|
|
348
|
+
|
|
349
|
+
---
|
|
350
|
+
|
|
49
351
|
## How Addressing Works
|
|
50
352
|
|
|
51
353
|
### Base Address Detection
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { EnergyAppStorage } from "@enyo-energy/energy-app-sdk/dist/packages/energy-app-storage.js";
|
|
2
|
+
import type { SunspecBatteryControls, SunspecInverterControls } from "./sunspec-interfaces.js";
|
|
3
|
+
export type CalibrationDeviceType = "inverter" | "battery";
|
|
4
|
+
/**
|
|
5
|
+
* Persisted snapshot of a device's writable control registers, taken at the start of a
|
|
6
|
+
* calibration. Used to roll the device back when calibration ends.
|
|
7
|
+
*
|
|
8
|
+
* `modifiedFields` records which control-block field names were touched by *other* write
|
|
9
|
+
* commands (SetInverterFeedInLimitV1, StartStorageGridChargeV1, etc.) while the calibration
|
|
10
|
+
* was active — on stop, only those fields are written back, leaving fields the calibration
|
|
11
|
+
* itself didn't disturb alone.
|
|
12
|
+
*/
|
|
13
|
+
export interface CalibrationSnapshot {
|
|
14
|
+
applianceId: string;
|
|
15
|
+
deviceType: CalibrationDeviceType;
|
|
16
|
+
unitId: number;
|
|
17
|
+
startedAtIso: string;
|
|
18
|
+
inverterControls?: SunspecInverterControls;
|
|
19
|
+
batteryControls?: SunspecBatteryControls;
|
|
20
|
+
modifiedFields: string[];
|
|
21
|
+
}
|
|
22
|
+
/** Calibration is capped at 5 minutes of wall time, including across restarts. */
|
|
23
|
+
export declare const CALIBRATION_AUTO_STOP_MS: number;
|
|
24
|
+
export type CalibrationRestoreReason = "stop" | "auto";
|
|
25
|
+
export type CalibrationRestoreCallback = (snapshot: CalibrationSnapshot, reason: CalibrationRestoreReason) => Promise<void>;
|
|
26
|
+
/**
|
|
27
|
+
* Per-appliance service that snapshots a SunSpec device's writable control registers when a
|
|
28
|
+
* calibration starts, tracks which registers other commands mutate while it is active, and
|
|
29
|
+
* restores those registers when calibration stops (either explicitly or via the 5-minute
|
|
30
|
+
* auto-stop timer).
|
|
31
|
+
*
|
|
32
|
+
* Snapshots are persisted via `EnergyAppStorage` so a crash/restart mid-calibration does not
|
|
33
|
+
* leave the device in a calibration state without a rollback. On `initialize()`, any stored
|
|
34
|
+
* snapshot is reloaded; the auto-stop deadline is computed from `startedAtIso` so total
|
|
35
|
+
* calibration time remains capped at `autoStopMs` across restarts.
|
|
36
|
+
*/
|
|
37
|
+
export declare class CalibrationSnapshotService {
|
|
38
|
+
private readonly storage;
|
|
39
|
+
private readonly applianceId;
|
|
40
|
+
private readonly onRestore;
|
|
41
|
+
private readonly autoStopMs;
|
|
42
|
+
private snapshot?;
|
|
43
|
+
private autoStopTimer?;
|
|
44
|
+
constructor(storage: EnergyAppStorage, applianceId: string, onRestore: CalibrationRestoreCallback, autoStopMs?: number);
|
|
45
|
+
private storageKey;
|
|
46
|
+
initialize(): Promise<void>;
|
|
47
|
+
isCalibrating(): boolean;
|
|
48
|
+
getSnapshot(): CalibrationSnapshot | undefined;
|
|
49
|
+
startCalibration(input: {
|
|
50
|
+
deviceType: CalibrationDeviceType;
|
|
51
|
+
unitId: number;
|
|
52
|
+
inverterControls?: SunspecInverterControls;
|
|
53
|
+
batteryControls?: SunspecBatteryControls;
|
|
54
|
+
}): Promise<void>;
|
|
55
|
+
recordModification(fields: string[]): Promise<void>;
|
|
56
|
+
/**
|
|
57
|
+
* Clear the timer, remove the snapshot from memory + storage, and return the snapshot so
|
|
58
|
+
* the caller can restore the modified registers. Returns undefined if no calibration was
|
|
59
|
+
* active.
|
|
60
|
+
*/
|
|
61
|
+
stopCalibration(): Promise<CalibrationSnapshot | undefined>;
|
|
62
|
+
private remainingMs;
|
|
63
|
+
private scheduleAutoStop;
|
|
64
|
+
private clearAutoStopTimer;
|
|
65
|
+
private fireAutoStop;
|
|
66
|
+
private persist;
|
|
67
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/** Calibration is capped at 5 minutes of wall time, including across restarts. */
|
|
2
|
+
export const CALIBRATION_AUTO_STOP_MS = 5 * 60 * 1000;
|
|
3
|
+
/**
|
|
4
|
+
* Per-appliance service that snapshots a SunSpec device's writable control registers when a
|
|
5
|
+
* calibration starts, tracks which registers other commands mutate while it is active, and
|
|
6
|
+
* restores those registers when calibration stops (either explicitly or via the 5-minute
|
|
7
|
+
* auto-stop timer).
|
|
8
|
+
*
|
|
9
|
+
* Snapshots are persisted via `EnergyAppStorage` so a crash/restart mid-calibration does not
|
|
10
|
+
* leave the device in a calibration state without a rollback. On `initialize()`, any stored
|
|
11
|
+
* snapshot is reloaded; the auto-stop deadline is computed from `startedAtIso` so total
|
|
12
|
+
* calibration time remains capped at `autoStopMs` across restarts.
|
|
13
|
+
*/
|
|
14
|
+
export class CalibrationSnapshotService {
|
|
15
|
+
storage;
|
|
16
|
+
applianceId;
|
|
17
|
+
onRestore;
|
|
18
|
+
autoStopMs;
|
|
19
|
+
snapshot;
|
|
20
|
+
autoStopTimer;
|
|
21
|
+
constructor(storage, applianceId, onRestore, autoStopMs = CALIBRATION_AUTO_STOP_MS) {
|
|
22
|
+
this.storage = storage;
|
|
23
|
+
this.applianceId = applianceId;
|
|
24
|
+
this.onRestore = onRestore;
|
|
25
|
+
this.autoStopMs = autoStopMs;
|
|
26
|
+
}
|
|
27
|
+
storageKey() {
|
|
28
|
+
return `sunspec-calibration-snapshot-${this.applianceId}`;
|
|
29
|
+
}
|
|
30
|
+
async initialize() {
|
|
31
|
+
try {
|
|
32
|
+
const loaded = await this.storage.load(this.storageKey());
|
|
33
|
+
if (!loaded) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
this.snapshot = loaded;
|
|
37
|
+
console.log(`CalibrationSnapshotService ${this.applianceId}: loaded persisted snapshot (started ${loaded.startedAtIso}, modifiedFields=[${loaded.modifiedFields.join(', ')}])`);
|
|
38
|
+
const remainingMs = this.remainingMs(loaded);
|
|
39
|
+
if (remainingMs <= 0) {
|
|
40
|
+
console.warn(`CalibrationSnapshotService ${this.applianceId}: snapshot exceeded ${this.autoStopMs}ms deadline — auto-restoring immediately`);
|
|
41
|
+
await this.fireAutoStop();
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
this.scheduleAutoStop(remainingMs);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
console.error(`CalibrationSnapshotService ${this.applianceId}: failed to load persisted snapshot: ${error}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
isCalibrating() {
|
|
52
|
+
return this.snapshot !== undefined;
|
|
53
|
+
}
|
|
54
|
+
getSnapshot() {
|
|
55
|
+
return this.snapshot;
|
|
56
|
+
}
|
|
57
|
+
async startCalibration(input) {
|
|
58
|
+
this.clearAutoStopTimer();
|
|
59
|
+
this.snapshot = {
|
|
60
|
+
applianceId: this.applianceId,
|
|
61
|
+
deviceType: input.deviceType,
|
|
62
|
+
unitId: input.unitId,
|
|
63
|
+
startedAtIso: new Date().toISOString(),
|
|
64
|
+
inverterControls: input.inverterControls,
|
|
65
|
+
batteryControls: input.batteryControls,
|
|
66
|
+
modifiedFields: [],
|
|
67
|
+
};
|
|
68
|
+
await this.persist();
|
|
69
|
+
this.scheduleAutoStop(this.autoStopMs);
|
|
70
|
+
console.log(`CalibrationSnapshotService ${this.applianceId}: calibration started for ${input.deviceType} (auto-stop in ${this.autoStopMs}ms)`);
|
|
71
|
+
}
|
|
72
|
+
async recordModification(fields) {
|
|
73
|
+
if (!this.snapshot) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
let added = false;
|
|
77
|
+
for (const field of fields) {
|
|
78
|
+
if (!this.snapshot.modifiedFields.includes(field)) {
|
|
79
|
+
this.snapshot.modifiedFields.push(field);
|
|
80
|
+
added = true;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (added) {
|
|
84
|
+
await this.persist();
|
|
85
|
+
console.log(`CalibrationSnapshotService ${this.applianceId}: tracked modified fields [${this.snapshot.modifiedFields.join(', ')}]`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Clear the timer, remove the snapshot from memory + storage, and return the snapshot so
|
|
90
|
+
* the caller can restore the modified registers. Returns undefined if no calibration was
|
|
91
|
+
* active.
|
|
92
|
+
*/
|
|
93
|
+
async stopCalibration() {
|
|
94
|
+
this.clearAutoStopTimer();
|
|
95
|
+
const snap = this.snapshot;
|
|
96
|
+
this.snapshot = undefined;
|
|
97
|
+
if (snap) {
|
|
98
|
+
try {
|
|
99
|
+
await this.storage.remove(this.storageKey());
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
console.error(`CalibrationSnapshotService ${this.applianceId}: failed to remove persisted snapshot: ${error}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return snap;
|
|
106
|
+
}
|
|
107
|
+
remainingMs(snap) {
|
|
108
|
+
const startedAtMs = Date.parse(snap.startedAtIso);
|
|
109
|
+
if (Number.isNaN(startedAtMs)) {
|
|
110
|
+
return this.autoStopMs;
|
|
111
|
+
}
|
|
112
|
+
return startedAtMs + this.autoStopMs - Date.now();
|
|
113
|
+
}
|
|
114
|
+
scheduleAutoStop(delayMs) {
|
|
115
|
+
this.clearAutoStopTimer();
|
|
116
|
+
this.autoStopTimer = setTimeout(() => {
|
|
117
|
+
void this.fireAutoStop();
|
|
118
|
+
}, delayMs);
|
|
119
|
+
if (typeof this.autoStopTimer.unref === "function") {
|
|
120
|
+
this.autoStopTimer.unref();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
clearAutoStopTimer() {
|
|
124
|
+
if (this.autoStopTimer) {
|
|
125
|
+
clearTimeout(this.autoStopTimer);
|
|
126
|
+
this.autoStopTimer = undefined;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
async fireAutoStop() {
|
|
130
|
+
const snap = this.snapshot;
|
|
131
|
+
this.snapshot = undefined;
|
|
132
|
+
this.clearAutoStopTimer();
|
|
133
|
+
if (!snap) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
await this.storage.remove(this.storageKey());
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
console.error(`CalibrationSnapshotService ${this.applianceId}: failed to remove persisted snapshot during auto-stop: ${error}`);
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
await this.onRestore(snap, "auto");
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
console.error(`CalibrationSnapshotService ${this.applianceId}: auto-stop restore failed: ${error}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
async persist() {
|
|
150
|
+
if (!this.snapshot) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
await this.storage.save(this.storageKey(), this.snapshot);
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
console.error(`CalibrationSnapshotService ${this.applianceId}: failed to persist snapshot: ${error}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|