@enyo-energy/energy-app-sdk 1.27.0 → 1.29.0

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 CHANGED
@@ -1646,10 +1646,14 @@ A closed, SDK-defined set of user-facing controls the active energy manager hono
1646
1646
  | `BatteryEvDischargeMode` | `fixed-wh` \| `soc-limit` \| `intelligent` \| `block-discharge` \| `unmanaged` |
1647
1647
  | `BatteryEvDischargeFixedWh` | number, **Wh** (only under `fixed-wh`) |
1648
1648
  | `BatteryEvDischargeSocLimitPercent` | number, **%** floor (only under `soc-limit`) |
1649
+ | `BatteryPvPriorityTargetSocPercent` | number \| `null`, **%** target the battery is charged to from PV before other surplus consumers (absent / `null` = disabled) |
1650
+ | `BatteryDischargeBlockAboveHomeConsumptionW` | number \| `null`, **W** home consumption above which the battery may not discharge (absent / `null` = disabled) |
1649
1651
  | `HeatpumpControl` | boolean |
1650
1652
  | `HeatingRodControl` | boolean |
1651
1653
  | `HeatingRodMode` | `pv-surplus-only` \| `boost` |
1654
+ | `HeatingRodBatteryDischargeSocLimitPercent` | number \| `null`, **%** floor the battery may feed the heating rod down to (absent / `null` = disabled) |
1652
1655
  | `ChargerControl` | boolean |
1656
+ | `GridOptimizedEvCharging` | boolean, off by default — cost-optimised charging on a static / time-of-use tariff avoids high grid load (by EPEX spot) |
1653
1657
  | `DefaultChargeMode` | `EnyoChargeModeEnum` |
1654
1658
  | `PriceLimitMode` | `ct-per-kwh` \| `cheapest-share` (absent = no ceiling) |
1655
1659
  | `PriceLimitCtPerKwh` | number, **ct/kWh** (`7` = 7 ct/kWh; only under `ct-per-kwh`) |
@@ -1694,7 +1698,11 @@ Three things to get right:
1694
1698
 
1695
1699
  - **Absent is not `false`.** `undefined` means unsupported or never chosen; `false` means the user switched it off. Check `supported` first, then the value — collapsing the two steers hardware someone deliberately disabled.
1696
1700
  - **How the battery may feed a charging EV is one mode plus its parameter.** `BatteryEvDischargeMode` picks the strategy; `BatteryEvDischargeFixedWh` and `BatteryEvDischargeSocLimitPercent` are read only under their own mode. `block-discharge` (actively hold the battery back) and `unmanaged` (do not intervene either way) are **not** synonyms.
1697
- - **Ten settings are gated by another** (`heatingRodMode` needs `heatingRodControl`, `priceLimitCtPerKwh` only applies under `priceLimitMode: 'ct-per-kwh'`, …). The tree is exported as `ENERGY_MANAGER_SETTING_DEPENDENCIES`, with two helpers over it: `getEnergyManagerSettingDependency(setting)` returns the direct gate and the value it must hold (or `null` for an ungated root), and `isEnergyManagerSettingActive(setting, values)` walks the whole chain — the gates are now two deep, so a Wh budget is live only when the mode is `fixed-wh` *and* battery control is on.
1701
+ - **Battery-first PV surplus is a charging target, not a floor.** `BatteryPvPriorityTargetSocPercent` (`40` = charge the battery to 40 % first) only holds back consumers that run *on surplus* — an EV on PV, a `pv-surplus-only` heating rod, heat pump surplus boosts, surplus automations. Grid-backed modes and explicit user requests are unaffected. It is independent of `BatteryEvDischargeSocLimitPercent`, which is a floor for discharging into the car. Disabled while absent or `null`; write `null` to switch it off again.
1702
+ - **The heating rod may draw from the battery only down to a floor, and only when asked.** `HeatingRodBatteryDischargeSocLimitPercent` (`80` = the rod may use the battery while it holds more than 80 %) is disabled while absent or `null`. It applies under both heating rod modes — under `pv-surplus-only` the battery above the floor counts as supply alongside PV, still never a grid import. It needs `batteryControl` to be enforced, and should not lie below `BatteryPvPriorityTargetSocPercent`; the validator warns on both. A consumer held back by it carries the command reason `BatteryPvPriority` (category `PvSurplus`) with `context.batteryPvPriority.targetSocPercent` and `socPercent`.
1703
+ - **The battery can be blocked from discharging under a heavy load.** `BatteryDischargeBlockAboveHomeConsumptionW` (`11000` = no discharge while the house draws more than 11 kW) keeps a sauna or an oven from emptying the battery in minutes. It is in **watts** — the validator warns below 100 W, which catches a kW value. Home consumption means the household plus every appliance the energy manager does **not** track — the same quantity as `homeConsumptionW` in the home consumption timeseries. Tracked appliances (wallbox, heat pump, heating rod, …) and the battery's own charging are excluded, so a charging car alone never trips the block. The block wins over the EV and heating rod discharge permissions; charging is unaffected. Disabled while absent or `null`. A battery held by it carries `HomeConsumptionHigh` with `context.batteryDischargeBlock.thresholdW` and `powerW` set to the current consumption.
1704
+ - **Grid-optimised EV charging is about the grid, not the bill.** `GridOptimizedEvCharging` adds no charge mode. It only affects `cost-optimized` sessions while the tariff is static or time-of-use (no effect under a dynamic one): the grid energy PV does not cover is placed in the hours with the lowest EPEX spot price before the deadline — the spot price as a proxy for grid load — instead of as late as possible or in the tariff's cheap windows. PV still comes first, the deadline still holds, and a session's price ceiling still applies against the customer's own tariff. On a time-of-use tariff a low-load hour can be a more expensive one; that is the trade the owner opts into.
1705
+ - **Fifteen settings are gated by another** (`heatingRodMode` needs `heatingRodControl`, `priceLimitCtPerKwh` only applies under `priceLimitMode: 'ct-per-kwh'`, …). The tree is exported as `ENERGY_MANAGER_SETTING_DEPENDENCIES`, with two helpers over it: `getEnergyManagerSettingDependency(setting)` returns the direct gate and the value it must hold (or `null` for an ungated root), and `isEnergyManagerSettingActive(setting, values)` walks the whole chain — the gates are now two deep, so a Wh budget is live only when the mode is `fixed-wh` *and* battery control is on.
1698
1706
  - **`priceLimitCtPerKwh` is in cents**, unlike the SDK's machine-readable price fields (`electricityPricePerKwh`, EUR/kWh). Divide by 100 when comparing. `validateEnergyManagerSettingsState()` warns on values outside a plausible ct band, which catches the mix-up.
1699
1707
  - **A price ceiling is one mode plus its parameter**, the same shape as the battery-to-EV one. `PriceLimitMode` picks how the ceiling is expressed — an absolute `ct-per-kwh` or a relative `cheapest-share` — and each value is read only under its own mode; the mode being absent means no ceiling at all. A relative ceiling ranks the price intervals *known* for the day and treats the cheapest `n` % as importable (the same rule as the `cheapest-share-of-day` automation trigger), so its effective threshold moves when tomorrow's prices publish — re-evaluate it rather than resolving it once.
1700
1708
 
@@ -1759,8 +1767,17 @@ An entry may carry an optional `gridFeeGrossPerKwh`: the gross grid fee **contai
1759
1767
  `pricePerKwh`, in currency units per kWh. It is a breakdown only — `pricePerKwh` is always the
1760
1768
  total, so never compute `pricePerKwh + gridFeeGrossPerKwh`.
1761
1769
 
1770
+ It may also carry an optional `spotPricePerKwh`: the net wholesale (e.g. EPEX day-ahead) price the
1771
+ interval's price is based on, as the provider reports it — in currency units per kWh, possibly negative.
1772
+ Informational too: never add it to `pricePerKwh`.
1773
+
1762
1774
  ```typescript
1763
- entries.push({ timestampIso: '2026-05-23T10:00:00Z', pricePerKwh: 0.31, gridFeeGrossPerKwh: 0.09 });
1775
+ entries.push({
1776
+ timestampIso: '2026-05-23T10:00:00Z',
1777
+ pricePerKwh: 0.31,
1778
+ gridFeeGrossPerKwh: 0.09,
1779
+ spotPricePerKwh: 0.084,
1780
+ });
1764
1781
  ```
1765
1782
 
1766
1783
  **Calling `setTariff` is the activation signal.** Return `AuthenticationRequired` or
@@ -4171,10 +4188,18 @@ New reason types came with this surface, so a skipped row can say *why* instead
4171
4188
 
4172
4189
  **Battery vs. charging car.** While a car charges, the house battery is either held out of it or allowed to help, according to the owner's `batteryEvDischargeMode`. Two reason types (category `BatteryState`) say which: `BatteryReservedFromEv` for the hold (`Discharge 0`) and `BatterySupportsEvCharging` for the release (`mode=Auto`). Both carry `context.batteryToEv` (`EnyoDataBusCommandReasonBatteryToEvContext`) with the owner's `mode`, the hold `trigger` (`EnyoBatteryToEvHoldTriggerEnum`: `OwnerBlocked`, `SocLimitReached`, `AllowanceSpent`, `NoMeasurement`), `socLimitPercent`, `allowanceWh` and `remainingWh`. The energy manager re-checks a hold periodically by briefly releasing the battery; that re-check sets `batteryToEv.probe: true` and should not be listed as its own entry in a command history.
4173
4190
 
4191
+ **Battery first on PV surplus.** When a surplus consumer (EV on PV, heating rod, heat pump boost, surplus automation) is held back or reduced because the battery is charged first up to the owner's `batteryPvPriorityTargetSocPercent`, the energy manager states `BatteryPvPriority` (category `PvSurplus`) on that consumer: `context.batteryPvPriority.targetSocPercent` for the target, `socPercent` for the pack's current SoC and `powerW` for the surplus the battery is taking — e.g. "The battery charges to 40 % first (now 28 %)."
4192
+
4193
+ **Grid-optimised charging.** A cost-optimised session placed in, or waiting for, a low-load hour because of `gridOptimizedEvCharging` carries `GridLoadOptimized` (category `Schedule`) with `context.placement` (chosen window and deadline) and `context.gridLoad` (`epexPricePerKwh`, `currency`). The EPEX price is a grid-load proxy, not what the customer pays, so the text must not promise a saving — e.g. "Charging from 02:00, when the grid is less busy — ready by 07:00."
4194
+
4195
+ **Battery vs. heating rod.** When the owner set `heatingRodBatteryDischargeSocLimitPercent`, two reason types (category `BatteryState`) say whether the battery feeds the rod: `BatterySupportsHeatingRod` while it may (with `powerW` for its contribution), `BatteryReservedFromHeatingRod` once it reaches the floor. Both carry `context.batteryToHeatingRod.socLimitPercent` and `socPercent` — e.g. "Heating rod no longer runs from the battery — it is at your 80 % limit."
4196
+
4174
4197
  **Short-cycling protection.** When an appliance is kept running (or kept off) to honour its minimum on/off time, state `DeviceProtection` with `context.switching` (`EnyoDataBusCommandReasonSwitchingContext`): `hold` (`EnyoSwitchingProtectionHoldEnum.KeptOn` / `KeptOff`), `minOnSeconds`, `minOffSeconds` and `untilIso`. The text can then say "Kept running for another 4 minutes to protect the compressor" instead of a generic "Protecting the device".
4175
4198
 
4176
4199
  A hold that keeps an appliance **off** after a stop has its own type, `RestartDelay` (category `DeviceProtection`): set `context.switching` with `hold: KeptOff`, `minOffSeconds` and `untilIso` (when it may restart), and `powerW` to the power waiting for it — e.g. "Short pause after switching off — charging resumes at 13:15 so the car isn't switched on and off too often."
4177
4200
 
4201
+ **Fluctuating PV surplus.** When the surplus jumps around (clouds passing) and the energy manager holds an appliance's power steady instead of following every dip, state `PvSurplusFluctuating` (category `PvSurplus`) with `powerW` set to the power being held — e.g. "Charging power held steady while clouds pass." / "Ladeleistung wird gehalten, solange Wolken durchziehen." It is a smoothing decision, told apart from `PvSurplusAvailable` / `PvSurplusUnavailable`.
4202
+
4178
4203
  ## Dynamic Grid Fees & Tariff Bonuses
4179
4204
 
4180
4205
  An electricity price is rarely one number. It is the energy price, plus the grid operator's network
@@ -67,6 +67,15 @@ const PRICE_LIMIT_MAX_CT = 200;
67
67
  * warned about for duplicating mode `block-discharge` rather than for being a unit slip.
68
68
  */
69
69
  const DISCHARGE_BUDGET_MIN_PLAUSIBLE_WH = 100;
70
+ /**
71
+ * Below this, a
72
+ * {@link EnergyManagerSettingValues.batteryDischargeBlockAboveHomeConsumptionW}
73
+ * threshold is almost certainly kW entered into a W field.
74
+ *
75
+ * `11` would block the battery above 11 W — that is, whenever the house draws
76
+ * anything — where 11 kW was plainly meant. `0` is caught by the same check.
77
+ */
78
+ const HOME_CONSUMPTION_BLOCK_MIN_PLAUSIBLE_W = 100;
70
79
  /** Every {@link EnergyManagerBatteryEvDischargeModeEnum} value. */
71
80
  const EV_DISCHARGE_MODES = new Set(Object.values(enyo_energy_manager_settings_js_1.EnergyManagerBatteryEvDischargeModeEnum));
72
81
  /** Value enums checked per field, for the enum-valued settings. */
@@ -139,6 +148,21 @@ function gateValue(values, setting) {
139
148
  * duplicates mode `block-discharge`, the second catches a kWh value in a Wh field).
140
149
  * - `batteryEvDischargeSocLimitPercent` is a finite number in 0…100 (**error**),
141
150
  * and not `100` (**warning** — a floor nothing can discharge past).
151
+ * - `batteryPvPriorityTargetSocPercent`, unless `null` (disabled), is a finite
152
+ * number in 0…100 (**error**), and neither `0` (**warning** — no priority at
153
+ * all, write `null`) nor `100` (**warning** — charging tapers near full, so
154
+ * surplus consumers can be held back for hours).
155
+ * - `heatingRodBatteryDischargeSocLimitPercent`, unless `null` (disabled), is a
156
+ * finite number in 0…100 (**error**), and not `100` (**warning** — a floor
157
+ * nothing can discharge past). Set while `batteryControl` is not `true`
158
+ * (**warning** — the floor cannot be enforced), or below
159
+ * `batteryPvPriorityTargetSocPercent` (**warning** — the rod drains what the
160
+ * PV priority refills).
161
+ * - `batteryDischargeBlockAboveHomeConsumptionW`, unless `null` (disabled), is
162
+ * a finite non-negative number (**error**), and not below 100 W (**warning**
163
+ * — catches a kW value in a W field; the battery would be blocked almost
164
+ * always).
165
+ * - `null` counts as unset for the "stored" checks below.
142
166
  * - A parameter belonging to a mode other than the selected one is flagged by
143
167
  * the gate check below, since {@link ENERGY_MANAGER_SETTING_DEPENDENCIES}
144
168
  * gates each on its own mode.
@@ -184,7 +208,7 @@ function validateEnergyManagerSettingsState(state) {
184
208
  const values = state.values ?? {};
185
209
  // ── value formats ──────────────────────────────────────────────────────
186
210
  for (const key of ['batteryControl', 'batteryChargeFromGrid', 'heatpumpControl',
187
- 'heatingRodControl', 'chargerControl']) {
211
+ 'heatingRodControl', 'chargerControl', 'gridOptimizedEvCharging']) {
188
212
  if (values[key] !== undefined && typeof values[key] !== 'boolean') {
189
213
  errors.push(`\`${key}\` must be a boolean when set.`);
190
214
  }
@@ -271,6 +295,67 @@ function validateEnergyManagerSettingsState(state) {
271
295
  'states that intent directly.');
272
296
  }
273
297
  }
298
+ if (values.batteryPvPriorityTargetSocPercent !== undefined
299
+ && values.batteryPvPriorityTargetSocPercent !== null) {
300
+ const target = values.batteryPvPriorityTargetSocPercent;
301
+ if (typeof target !== 'number' || !Number.isFinite(target)) {
302
+ errors.push('`batteryPvPriorityTargetSocPercent` must be a finite number when set.');
303
+ }
304
+ else if (target < 0 || target > 100) {
305
+ errors.push(`\`batteryPvPriorityTargetSocPercent\` is ${target}; it is a percentage and must lie ` +
306
+ 'between 0 and 100.');
307
+ }
308
+ else if (target === 0) {
309
+ warnings.push('`batteryPvPriorityTargetSocPercent` is 0, which gives the battery no priority at all — ' +
310
+ 'write `null` to say that outright.');
311
+ }
312
+ else if (target === 100) {
313
+ warnings.push('`batteryPvPriorityTargetSocPercent` is 100. Charging tapers as the battery nears full, ' +
314
+ 'so PV-surplus consumers may be held back for hours waiting for the last percent.');
315
+ }
316
+ }
317
+ if (values.batteryDischargeBlockAboveHomeConsumptionW !== undefined
318
+ && values.batteryDischargeBlockAboveHomeConsumptionW !== null) {
319
+ const threshold = values.batteryDischargeBlockAboveHomeConsumptionW;
320
+ if (typeof threshold !== 'number' || !Number.isFinite(threshold)) {
321
+ errors.push('`batteryDischargeBlockAboveHomeConsumptionW` must be a finite number or null when set.');
322
+ }
323
+ else if (threshold < 0) {
324
+ errors.push(`\`batteryDischargeBlockAboveHomeConsumptionW\` is ${threshold}; a consumption threshold ` +
325
+ 'cannot be negative.');
326
+ }
327
+ else if (threshold < HOME_CONSUMPTION_BLOCK_MIN_PLAUSIBLE_W) {
328
+ warnings.push(`\`batteryDischargeBlockAboveHomeConsumptionW\` is ${threshold} W, so the battery would be ` +
329
+ 'blocked almost always — this field is in watts, not kilowatts.');
330
+ }
331
+ }
332
+ if (values.heatingRodBatteryDischargeSocLimitPercent !== undefined
333
+ && values.heatingRodBatteryDischargeSocLimitPercent !== null) {
334
+ const floor = values.heatingRodBatteryDischargeSocLimitPercent;
335
+ if (typeof floor !== 'number' || !Number.isFinite(floor)) {
336
+ errors.push('`heatingRodBatteryDischargeSocLimitPercent` must be a finite number or null when set.');
337
+ }
338
+ else if (floor < 0 || floor > 100) {
339
+ errors.push(`\`heatingRodBatteryDischargeSocLimitPercent\` is ${floor}; it is a percentage and must lie ` +
340
+ 'between 0 and 100.');
341
+ }
342
+ else {
343
+ if (floor === 100) {
344
+ warnings.push('`heatingRodBatteryDischargeSocLimitPercent` is 100, a floor the battery is already at, ' +
345
+ 'so the rod can never draw from it — write `null` to disable it outright.');
346
+ }
347
+ if (values.batteryControl !== true) {
348
+ warnings.push('`heatingRodBatteryDischargeSocLimitPercent` is set, but `batteryControl` is not true — ' +
349
+ 'the energy manager cannot hold the battery at the floor.');
350
+ }
351
+ const priority = values.batteryPvPriorityTargetSocPercent;
352
+ if (typeof priority === 'number' && Number.isFinite(priority) && floor < priority) {
353
+ warnings.push(`\`heatingRodBatteryDischargeSocLimitPercent\` (${floor} %) is below ` +
354
+ `\`batteryPvPriorityTargetSocPercent\` (${priority} %) — the rod drains the battery ` +
355
+ 'into a range the PV priority then refills.');
356
+ }
357
+ }
358
+ }
274
359
  if (values.costOptimizedTargetTime !== undefined) {
275
360
  if (typeof values.costOptimizedTargetTime !== 'string'
276
361
  || !TIME_RE.test(values.costOptimizedTargetTime)) {
@@ -290,7 +375,7 @@ function validateEnergyManagerSettingsState(state) {
290
375
  // ── stored but unsupported ─────────────────────────────────────────────
291
376
  for (const setting of Object.values(enyo_energy_manager_settings_js_1.EnergyManagerSettingEnum)) {
292
377
  const keys = enyo_energy_manager_settings_js_1.ENERGY_MANAGER_SETTING_VALUE_KEYS[setting];
293
- const held = keys.filter((key) => values[key] !== undefined);
378
+ const held = keys.filter((key) => values[key] !== undefined && values[key] !== null);
294
379
  if (held.length && !supported.has(setting)) {
295
380
  warnings.push(`"${setting}" holds a value (${held.join(', ')}) but is not in \`supported\` — ` +
296
381
  'the energy manager will ignore it.');
@@ -301,7 +386,8 @@ function validateEnergyManagerSettingsState(state) {
301
386
  if (!dependency)
302
387
  continue;
303
388
  const key = setting;
304
- const held = enyo_energy_manager_settings_js_1.ENERGY_MANAGER_SETTING_VALUE_KEYS[key].some((k) => values[k] !== undefined);
389
+ const held = enyo_energy_manager_settings_js_1.ENERGY_MANAGER_SETTING_VALUE_KEYS[key]
390
+ .some((k) => values[k] !== undefined && values[k] !== null);
305
391
  if (held && gateValue(values, dependency.requires) !== dependency.equals) {
306
392
  warnings.push(`"${key}" holds a value, but it only takes effect while "${dependency.requires}" ` +
307
393
  `is ${JSON.stringify(dependency.equals)} — it currently cannot.`);
@@ -65,6 +65,21 @@ export interface EnergyManagerSettingsValidationResult {
65
65
  * duplicates mode `block-discharge`, the second catches a kWh value in a Wh field).
66
66
  * - `batteryEvDischargeSocLimitPercent` is a finite number in 0…100 (**error**),
67
67
  * and not `100` (**warning** — a floor nothing can discharge past).
68
+ * - `batteryPvPriorityTargetSocPercent`, unless `null` (disabled), is a finite
69
+ * number in 0…100 (**error**), and neither `0` (**warning** — no priority at
70
+ * all, write `null`) nor `100` (**warning** — charging tapers near full, so
71
+ * surplus consumers can be held back for hours).
72
+ * - `heatingRodBatteryDischargeSocLimitPercent`, unless `null` (disabled), is a
73
+ * finite number in 0…100 (**error**), and not `100` (**warning** — a floor
74
+ * nothing can discharge past). Set while `batteryControl` is not `true`
75
+ * (**warning** — the floor cannot be enforced), or below
76
+ * `batteryPvPriorityTargetSocPercent` (**warning** — the rod drains what the
77
+ * PV priority refills).
78
+ * - `batteryDischargeBlockAboveHomeConsumptionW`, unless `null` (disabled), is
79
+ * a finite non-negative number (**error**), and not below 100 W (**warning**
80
+ * — catches a kW value in a W field; the battery would be blocked almost
81
+ * always).
82
+ * - `null` counts as unset for the "stored" checks below.
68
83
  * - A parameter belonging to a mode other than the selected one is flagged by
69
84
  * the gate check below, since {@link ENERGY_MANAGER_SETTING_DEPENDENCIES}
70
85
  * gates each on its own mode.
@@ -38,6 +38,19 @@ var EnyoDataBusCommandReasonTypeEnum;
38
38
  * saying "elsewhere".
39
39
  */
40
40
  EnyoDataBusCommandReasonTypeEnum["PvSurplusAllocatedElsewhere"] = "pv-surplus-allocated-elsewhere";
41
+ /**
42
+ * The PV surplus is fluctuating (e.g. clouds passing) and the energy manager
43
+ * deliberately holds the appliance's power steady instead of following
44
+ * every dip and peak — "Ladeleistung wird gehalten, solange Wolken
45
+ * durchziehen." / "Charging power held steady while clouds pass."
46
+ *
47
+ * Says the command is a smoothing decision, not a reaction to the surplus
48
+ * being there or gone: told apart from {@link PvSurplusAvailable} and
49
+ * {@link PvSurplusUnavailable}. Set {@link EnyoDataBusCommandReason.powerW}
50
+ * to the power being held. Category
51
+ * {@link EnyoDataBusCommandReasonCategoryEnum.PvSurplus}.
52
+ */
53
+ EnyoDataBusCommandReasonTypeEnum["PvSurplusFluctuating"] = "pv-surplus-fluctuating";
41
54
  /** Command issued because battery capacity is available */
42
55
  EnyoDataBusCommandReasonTypeEnum["BatteryCapacityAvailable"] = "battery-capacity-available";
43
56
  /** Command issued because battery capacity is unavailable */
@@ -82,7 +95,19 @@ var EnyoDataBusCommandReasonTypeEnum;
82
95
  * {@link RestartDelay}.
83
96
  */
84
97
  EnyoDataBusCommandReasonTypeEnum["DeviceProtection"] = "device-protection";
85
- /** Command issued because home consumption is high */
98
+ /**
99
+ * Command issued because home consumption is high.
100
+ *
101
+ * When the energy manager holds the battery because home consumption — the
102
+ * household plus untracked appliances, tracked appliances excluded — is
103
+ * above the owner's `batteryDischargeBlockAboveHomeConsumptionW`, set
104
+ * {@link EnyoDataBusCommandReasonContext.batteryDischargeBlock} with the
105
+ * threshold, {@link EnyoDataBusCommandReason.powerW} to the current home
106
+ * consumption and category
107
+ * {@link EnyoDataBusCommandReasonCategoryEnum.BatteryState} — so the text
108
+ * can say "Battery paused: home consumption is 13.2 kW, above your 11 kW
109
+ * limit."
110
+ */
86
111
  EnyoDataBusCommandReasonTypeEnum["HomeConsumptionHigh"] = "home-consumption-high";
87
112
  /** Command issued to optimize self-consumption */
88
113
  EnyoDataBusCommandReasonTypeEnum["SelfConsumptionOptimization"] = "self-consumption-optimization";
@@ -229,6 +254,70 @@ var EnyoDataBusCommandReasonTypeEnum;
229
254
  * the appliance from short-cycling and is not about price, sun or contention.
230
255
  */
231
256
  EnyoDataBusCommandReasonTypeEnum["RestartDelay"] = "restart-delay";
257
+ /**
258
+ * A PV-surplus consumer (EV on PV, heating rod, heat pump surplus boost,
259
+ * surplus automation) is held back or reduced because the house battery is
260
+ * charged first, up to the owner's `batteryPvPriorityTargetSocPercent`.
261
+ *
262
+ * Stated by the energy manager's surplus allocation on the consumer that
263
+ * lost out, never on the battery itself. Set
264
+ * {@link EnyoDataBusCommandReasonContext.batteryPvPriority} with the
265
+ * target, {@link EnyoDataBusCommandReason.socPercent} to the pack's current
266
+ * SoC, and {@link EnyoDataBusCommandReason.powerW} to the surplus the
267
+ * battery is taking — so the text can say "The battery charges to 40 %
268
+ * first (now 28 %)."
269
+ *
270
+ * Told apart from {@link PvSurplusAllocatedElsewhere}: the surplus did not
271
+ * go to another appliance by the planner's choice, it went to the battery
272
+ * because the owner asked for it. Category
273
+ * {@link EnyoDataBusCommandReasonCategoryEnum.PvSurplus} — it is about who
274
+ * got the sun.
275
+ */
276
+ EnyoDataBusCommandReasonTypeEnum["BatteryPvPriority"] = "battery-pv-priority";
277
+ /**
278
+ * The house battery is allowed to feed the heating rod, down to the
279
+ * owner's `heatingRodBatteryDischargeSocLimitPercent`.
280
+ *
281
+ * Set {@link EnyoDataBusCommandReasonContext.batteryToHeatingRod} with the
282
+ * floor, {@link EnyoDataBusCommandReason.socPercent} to the pack's current
283
+ * SoC and {@link EnyoDataBusCommandReason.powerW} to the power the battery
284
+ * contributes. Category
285
+ * {@link EnyoDataBusCommandReasonCategoryEnum.BatteryState}.
286
+ */
287
+ EnyoDataBusCommandReasonTypeEnum["BatterySupportsHeatingRod"] = "battery-supports-heating-rod";
288
+ /**
289
+ * The house battery is held back from the heating rod because it has
290
+ * reached the owner's floor (`heatingRodBatteryDischargeSocLimitPercent`).
291
+ * The rod runs on PV surplus only — or, under boost, from the grid.
292
+ *
293
+ * The counterpart of {@link BatterySupportsHeatingRod}. Set
294
+ * {@link EnyoDataBusCommandReasonContext.batteryToHeatingRod} and
295
+ * {@link EnyoDataBusCommandReason.socPercent}. Told apart from
296
+ * {@link BatterySoCLow}: the pack is not low, it is at the floor the owner
297
+ * chose for the rod. Category
298
+ * {@link EnyoDataBusCommandReasonCategoryEnum.BatteryState}.
299
+ */
300
+ EnyoDataBusCommandReasonTypeEnum["BatteryReservedFromHeatingRod"] = "battery-reserved-from-heating-rod";
301
+ /**
302
+ * A cost-optimised charging session was placed in — or is waiting for — a
303
+ * low-load hour on the grid, because the owner switched on
304
+ * `gridOptimizedEvCharging`. Covers both the "charging now" and the
305
+ * "waiting" side of the decision.
306
+ *
307
+ * About relieving the grid, not about the customer's price: on a static or
308
+ * time-of-use tariff the EPEX spot price that ranked the slot is not what
309
+ * the customer pays, so the text must not promise a saving — which is why
310
+ * this is its own type rather than
311
+ * {@link ElectricityPriceBelowThreshold} or {@link WaitingForCheaperSlot}.
312
+ *
313
+ * Set {@link EnyoDataBusCommandReasonContext.placement} with the chosen
314
+ * window and the session's deadline, and
315
+ * {@link EnyoDataBusCommandReasonContext.gridLoad} with the spot price
316
+ * behind it — so the text can say "Charging from 02:00, when the grid is
317
+ * less busy — ready by 07:00." Category
318
+ * {@link EnyoDataBusCommandReasonCategoryEnum.Schedule}.
319
+ */
320
+ EnyoDataBusCommandReasonTypeEnum["GridLoadOptimized"] = "grid-load-optimized";
232
321
  })(EnyoDataBusCommandReasonTypeEnum || (exports.EnyoDataBusCommandReasonTypeEnum = EnyoDataBusCommandReasonTypeEnum = {}));
233
322
  /**
234
323
  * Coarse, machine-readable grouping of why a data bus command was issued.
@@ -54,6 +54,19 @@ export declare enum EnyoDataBusCommandReasonTypeEnum {
54
54
  * saying "elsewhere".
55
55
  */
56
56
  PvSurplusAllocatedElsewhere = "pv-surplus-allocated-elsewhere",
57
+ /**
58
+ * The PV surplus is fluctuating (e.g. clouds passing) and the energy manager
59
+ * deliberately holds the appliance's power steady instead of following
60
+ * every dip and peak — "Ladeleistung wird gehalten, solange Wolken
61
+ * durchziehen." / "Charging power held steady while clouds pass."
62
+ *
63
+ * Says the command is a smoothing decision, not a reaction to the surplus
64
+ * being there or gone: told apart from {@link PvSurplusAvailable} and
65
+ * {@link PvSurplusUnavailable}. Set {@link EnyoDataBusCommandReason.powerW}
66
+ * to the power being held. Category
67
+ * {@link EnyoDataBusCommandReasonCategoryEnum.PvSurplus}.
68
+ */
69
+ PvSurplusFluctuating = "pv-surplus-fluctuating",
57
70
  /** Command issued because battery capacity is available */
58
71
  BatteryCapacityAvailable = "battery-capacity-available",
59
72
  /** Command issued because battery capacity is unavailable */
@@ -98,7 +111,19 @@ export declare enum EnyoDataBusCommandReasonTypeEnum {
98
111
  * {@link RestartDelay}.
99
112
  */
100
113
  DeviceProtection = "device-protection",
101
- /** Command issued because home consumption is high */
114
+ /**
115
+ * Command issued because home consumption is high.
116
+ *
117
+ * When the energy manager holds the battery because home consumption — the
118
+ * household plus untracked appliances, tracked appliances excluded — is
119
+ * above the owner's `batteryDischargeBlockAboveHomeConsumptionW`, set
120
+ * {@link EnyoDataBusCommandReasonContext.batteryDischargeBlock} with the
121
+ * threshold, {@link EnyoDataBusCommandReason.powerW} to the current home
122
+ * consumption and category
123
+ * {@link EnyoDataBusCommandReasonCategoryEnum.BatteryState} — so the text
124
+ * can say "Battery paused: home consumption is 13.2 kW, above your 11 kW
125
+ * limit."
126
+ */
102
127
  HomeConsumptionHigh = "home-consumption-high",
103
128
  /** Command issued to optimize self-consumption */
104
129
  SelfConsumptionOptimization = "self-consumption-optimization",
@@ -244,7 +269,71 @@ export declare enum EnyoDataBusCommandReasonTypeEnum {
244
269
  * {@link EnyoDataBusCommandReasonCategoryEnum.DeviceProtection} — it protects
245
270
  * the appliance from short-cycling and is not about price, sun or contention.
246
271
  */
247
- RestartDelay = "restart-delay"
272
+ RestartDelay = "restart-delay",
273
+ /**
274
+ * A PV-surplus consumer (EV on PV, heating rod, heat pump surplus boost,
275
+ * surplus automation) is held back or reduced because the house battery is
276
+ * charged first, up to the owner's `batteryPvPriorityTargetSocPercent`.
277
+ *
278
+ * Stated by the energy manager's surplus allocation on the consumer that
279
+ * lost out, never on the battery itself. Set
280
+ * {@link EnyoDataBusCommandReasonContext.batteryPvPriority} with the
281
+ * target, {@link EnyoDataBusCommandReason.socPercent} to the pack's current
282
+ * SoC, and {@link EnyoDataBusCommandReason.powerW} to the surplus the
283
+ * battery is taking — so the text can say "The battery charges to 40 %
284
+ * first (now 28 %)."
285
+ *
286
+ * Told apart from {@link PvSurplusAllocatedElsewhere}: the surplus did not
287
+ * go to another appliance by the planner's choice, it went to the battery
288
+ * because the owner asked for it. Category
289
+ * {@link EnyoDataBusCommandReasonCategoryEnum.PvSurplus} — it is about who
290
+ * got the sun.
291
+ */
292
+ BatteryPvPriority = "battery-pv-priority",
293
+ /**
294
+ * The house battery is allowed to feed the heating rod, down to the
295
+ * owner's `heatingRodBatteryDischargeSocLimitPercent`.
296
+ *
297
+ * Set {@link EnyoDataBusCommandReasonContext.batteryToHeatingRod} with the
298
+ * floor, {@link EnyoDataBusCommandReason.socPercent} to the pack's current
299
+ * SoC and {@link EnyoDataBusCommandReason.powerW} to the power the battery
300
+ * contributes. Category
301
+ * {@link EnyoDataBusCommandReasonCategoryEnum.BatteryState}.
302
+ */
303
+ BatterySupportsHeatingRod = "battery-supports-heating-rod",
304
+ /**
305
+ * The house battery is held back from the heating rod because it has
306
+ * reached the owner's floor (`heatingRodBatteryDischargeSocLimitPercent`).
307
+ * The rod runs on PV surplus only — or, under boost, from the grid.
308
+ *
309
+ * The counterpart of {@link BatterySupportsHeatingRod}. Set
310
+ * {@link EnyoDataBusCommandReasonContext.batteryToHeatingRod} and
311
+ * {@link EnyoDataBusCommandReason.socPercent}. Told apart from
312
+ * {@link BatterySoCLow}: the pack is not low, it is at the floor the owner
313
+ * chose for the rod. Category
314
+ * {@link EnyoDataBusCommandReasonCategoryEnum.BatteryState}.
315
+ */
316
+ BatteryReservedFromHeatingRod = "battery-reserved-from-heating-rod",
317
+ /**
318
+ * A cost-optimised charging session was placed in — or is waiting for — a
319
+ * low-load hour on the grid, because the owner switched on
320
+ * `gridOptimizedEvCharging`. Covers both the "charging now" and the
321
+ * "waiting" side of the decision.
322
+ *
323
+ * About relieving the grid, not about the customer's price: on a static or
324
+ * time-of-use tariff the EPEX spot price that ranked the slot is not what
325
+ * the customer pays, so the text must not promise a saving — which is why
326
+ * this is its own type rather than
327
+ * {@link ElectricityPriceBelowThreshold} or {@link WaitingForCheaperSlot}.
328
+ *
329
+ * Set {@link EnyoDataBusCommandReasonContext.placement} with the chosen
330
+ * window and the session's deadline, and
331
+ * {@link EnyoDataBusCommandReasonContext.gridLoad} with the spot price
332
+ * behind it — so the text can say "Charging from 02:00, when the grid is
333
+ * less busy — ready by 07:00." Category
334
+ * {@link EnyoDataBusCommandReasonCategoryEnum.Schedule}.
335
+ */
336
+ GridLoadOptimized = "grid-load-optimized"
248
337
  }
249
338
  /**
250
339
  * Coarse, machine-readable grouping of why a data bus command was issued.
@@ -538,6 +627,131 @@ export interface EnyoDataBusCommandReasonContext {
538
627
  * protection is against short-cycling (kept on).
539
628
  */
540
629
  switching?: EnyoDataBusCommandReasonSwitchingContext;
630
+ /**
631
+ * The owner's battery-first target (`batteryPvPriorityTargetSocPercent`).
632
+ * Set on {@link EnyoDataBusCommandReasonTypeEnum.BatteryPvPriority}.
633
+ */
634
+ batteryPvPriority?: EnyoDataBusCommandReasonBatteryPvPriorityContext;
635
+ /**
636
+ * The owner's battery-to-heating-rod floor
637
+ * (`heatingRodBatteryDischargeSocLimitPercent`). Set on
638
+ * {@link EnyoDataBusCommandReasonTypeEnum.BatterySupportsHeatingRod} and
639
+ * {@link EnyoDataBusCommandReasonTypeEnum.BatteryReservedFromHeatingRod}.
640
+ */
641
+ batteryToHeatingRod?: EnyoDataBusCommandReasonBatteryToHeatingRodContext;
642
+ /**
643
+ * The owner's home-consumption threshold above which the battery may not
644
+ * discharge (`batteryDischargeBlockAboveHomeConsumptionW`). Set on
645
+ * {@link EnyoDataBusCommandReasonTypeEnum.HomeConsumptionHigh} when that
646
+ * threshold is why the battery is held.
647
+ */
648
+ batteryDischargeBlock?: EnyoDataBusCommandReasonBatteryDischargeBlockContext;
649
+ /**
650
+ * The EPEX spot price that stood in for grid load. Set on
651
+ * {@link EnyoDataBusCommandReasonTypeEnum.GridLoadOptimized}.
652
+ */
653
+ gridLoad?: EnyoDataBusCommandReasonGridLoadContext;
654
+ }
655
+ /**
656
+ * The market signal behind a
657
+ * {@link EnyoDataBusCommandReasonTypeEnum.GridLoadOptimized} decision.
658
+ *
659
+ * The EPEX spot price is used as a proxy for how heavily the grid is loaded —
660
+ * it is **not** what the customer pays, which is why it lives here and not in
661
+ * {@link EnyoDataBusCommandReason.electricityPricePerKwh}.
662
+ *
663
+ * @example
664
+ * ```typescript
665
+ * // Waiting for the low-load hours from 02:00 to finish by 07:00:
666
+ * const reason: EnyoDataBusCommandReason = {
667
+ * type: EnyoDataBusCommandReasonTypeEnum.GridLoadOptimized,
668
+ * category: EnyoDataBusCommandReasonCategoryEnum.Schedule,
669
+ * context: {
670
+ * placement: {
671
+ * startIso: '2026-10-13T02:00:00+02:00',
672
+ * endIso: '2026-10-13T04:30:00+02:00',
673
+ * deadlineIso: '2026-10-13T07:00:00+02:00',
674
+ * },
675
+ * gridLoad: {epexPricePerKwh: 0.042, currency: EnyoCurrencyEnum.EUR},
676
+ * },
677
+ * };
678
+ * ```
679
+ */
680
+ export interface EnyoDataBusCommandReasonGridLoadContext {
681
+ /** EPEX spot price per kWh of the chosen slot, in {@link currency} — a grid-load proxy. */
682
+ epexPricePerKwh: number;
683
+ /** Currency of {@link epexPricePerKwh}. */
684
+ currency: EnyoCurrencyEnum;
685
+ }
686
+ /**
687
+ * The owner's home-consumption threshold
688
+ * (`batteryDischargeBlockAboveHomeConsumptionW`) the battery is held under —
689
+ * the structured backing of
690
+ * {@link EnyoDataBusCommandReasonTypeEnum.HomeConsumptionHigh} when it blocks
691
+ * the battery. The current home consumption travels in
692
+ * {@link EnyoDataBusCommandReason.powerW}, measured like
693
+ * {@link HomeConsumptionTimeseriesEntry.homeConsumptionW}: household plus
694
+ * untracked appliances, tracked appliances excluded.
695
+ *
696
+ * @example
697
+ * ```typescript
698
+ * // Home consumption is 13.2 kW, above the owner's 11 kW threshold — hold the battery:
699
+ * const reason: EnyoDataBusCommandReason = {
700
+ * type: EnyoDataBusCommandReasonTypeEnum.HomeConsumptionHigh,
701
+ * category: EnyoDataBusCommandReasonCategoryEnum.BatteryState,
702
+ * powerW: 13200,
703
+ * context: {batteryDischargeBlock: {thresholdW: 11000}},
704
+ * };
705
+ * ```
706
+ */
707
+ export interface EnyoDataBusCommandReasonBatteryDischargeBlockContext {
708
+ /** The home consumption, in W, above which the battery may not discharge. */
709
+ thresholdW: number;
710
+ }
711
+ /**
712
+ * The owner's battery-to-heating-rod floor
713
+ * (`heatingRodBatteryDischargeSocLimitPercent`) a decision was taken under —
714
+ * the structured backing of
715
+ * {@link EnyoDataBusCommandReasonTypeEnum.BatterySupportsHeatingRod} and
716
+ * {@link EnyoDataBusCommandReasonTypeEnum.BatteryReservedFromHeatingRod}. The
717
+ * pack's current SoC travels in {@link EnyoDataBusCommandReason.socPercent}.
718
+ *
719
+ * @example
720
+ * ```typescript
721
+ * // The battery reaches the owner's 80 % floor — the rod stops drawing from it:
722
+ * const reason: EnyoDataBusCommandReason = {
723
+ * type: EnyoDataBusCommandReasonTypeEnum.BatteryReservedFromHeatingRod,
724
+ * category: EnyoDataBusCommandReasonCategoryEnum.BatteryState,
725
+ * socPercent: 80,
726
+ * context: {batteryToHeatingRod: {socLimitPercent: 80}},
727
+ * };
728
+ * ```
729
+ */
730
+ export interface EnyoDataBusCommandReasonBatteryToHeatingRodContext {
731
+ /** The SoC floor, in percent, the battery may feed the rod down to. */
732
+ socLimitPercent: number;
733
+ }
734
+ /**
735
+ * The owner's battery-first setting (`batteryPvPriorityTargetSocPercent`) a
736
+ * PV-surplus consumer was held back under — the structured backing of
737
+ * {@link EnyoDataBusCommandReasonTypeEnum.BatteryPvPriority}. The pack's
738
+ * current SoC travels in {@link EnyoDataBusCommandReason.socPercent}.
739
+ *
740
+ * @example
741
+ * ```typescript
742
+ * // The wallbox gets no PV surplus while the battery charges to 40 %:
743
+ * const reason: EnyoDataBusCommandReason = {
744
+ * type: EnyoDataBusCommandReasonTypeEnum.BatteryPvPriority,
745
+ * category: EnyoDataBusCommandReasonCategoryEnum.PvSurplus,
746
+ * socPercent: 28,
747
+ * powerW: 3200,
748
+ * context: {batteryPvPriority: {targetSocPercent: 40}},
749
+ * };
750
+ * ```
751
+ */
752
+ export interface EnyoDataBusCommandReasonBatteryPvPriorityContext {
753
+ /** The SoC, in percent, the battery is charged to before surplus is shared. */
754
+ targetSocPercent: number;
541
755
  }
542
756
  /**
543
757
  * Which part of the owner's battery-to-EV setting
@@ -189,6 +189,19 @@ export interface EnyoTariffPriceEntry {
189
189
  * series declares {@link EnyoPriceComponentEnum.GridFee} in its `includes`.
190
190
  */
191
191
  gridFeeGrossPerKwh?: number;
192
+ /**
193
+ * Optional wholesale spot price per kWh this interval's price is based on,
194
+ * as the provider reports it — typically the EPEX day-ahead price for a
195
+ * dynamic tariff. In the tariff's currency (not cent), net: without grid
196
+ * fee, taxes, levies or supplier margin. May be negative.
197
+ *
198
+ * Informational only, like {@link gridFeeGrossPerKwh}: it shows what the
199
+ * price was derived from and is **never added** to {@link pricePerKwh},
200
+ * which is always the total price. Set it when the provider hands it out;
201
+ * omit it otherwise — consumers needing a spot price without it can read
202
+ * `useEpexSpotPrices()`.
203
+ */
204
+ spotPricePerKwh?: number;
192
205
  }
193
206
  /**
194
207
  * Prices for one direction over a requested range.