@enyo-energy/energy-app-sdk 1.28.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
 
@@ -4180,6 +4188,12 @@ New reason types came with this surface, so a skipped row can say *why* instead
4180
4188
 
4181
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.
4182
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
+
4183
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".
4184
4198
 
4185
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."
@@ -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.
@@ -95,7 +95,19 @@ var EnyoDataBusCommandReasonTypeEnum;
95
95
  * {@link RestartDelay}.
96
96
  */
97
97
  EnyoDataBusCommandReasonTypeEnum["DeviceProtection"] = "device-protection";
98
- /** 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
+ */
99
111
  EnyoDataBusCommandReasonTypeEnum["HomeConsumptionHigh"] = "home-consumption-high";
100
112
  /** Command issued to optimize self-consumption */
101
113
  EnyoDataBusCommandReasonTypeEnum["SelfConsumptionOptimization"] = "self-consumption-optimization";
@@ -242,6 +254,70 @@ var EnyoDataBusCommandReasonTypeEnum;
242
254
  * the appliance from short-cycling and is not about price, sun or contention.
243
255
  */
244
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";
245
321
  })(EnyoDataBusCommandReasonTypeEnum || (exports.EnyoDataBusCommandReasonTypeEnum = EnyoDataBusCommandReasonTypeEnum = {}));
246
322
  /**
247
323
  * Coarse, machine-readable grouping of why a data bus command was issued.
@@ -111,7 +111,19 @@ export declare enum EnyoDataBusCommandReasonTypeEnum {
111
111
  * {@link RestartDelay}.
112
112
  */
113
113
  DeviceProtection = "device-protection",
114
- /** 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
+ */
115
127
  HomeConsumptionHigh = "home-consumption-high",
116
128
  /** Command issued to optimize self-consumption */
117
129
  SelfConsumptionOptimization = "self-consumption-optimization",
@@ -257,7 +269,71 @@ export declare enum EnyoDataBusCommandReasonTypeEnum {
257
269
  * {@link EnyoDataBusCommandReasonCategoryEnum.DeviceProtection} — it protects
258
270
  * the appliance from short-cycling and is not about price, sun or contention.
259
271
  */
260
- 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"
261
337
  }
262
338
  /**
263
339
  * Coarse, machine-readable grouping of why a data bus command was issued.
@@ -551,6 +627,131 @@ export interface EnyoDataBusCommandReasonContext {
551
627
  * protection is against short-cycling (kept on).
552
628
  */
553
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;
554
755
  }
555
756
  /**
556
757
  * Which part of the owner's battery-to-EV setting
@@ -50,6 +50,16 @@ var EnergyManagerSettingEnum;
50
50
  EnergyManagerSettingEnum["BatteryEvDischargeFixedWh"] = "battery-ev-discharge-fixed-wh";
51
51
  /** The SoC floor, in %. Only under {@link EnergyManagerBatteryEvDischargeModeEnum.SocLimit}. */
52
52
  EnergyManagerSettingEnum["BatteryEvDischargeSocLimitPercent"] = "battery-ev-discharge-soc-limit-percent";
53
+ /**
54
+ * The SoC, in %, the battery is charged to from PV surplus before any other
55
+ * surplus consumer (EV, heating rod, …) gets a share.
56
+ */
57
+ EnergyManagerSettingEnum["BatteryPvPriorityTargetSocPercent"] = "battery-pv-priority-target-soc-percent";
58
+ /**
59
+ * The home consumption, in W, above which the battery must not discharge.
60
+ * Unset or `null` means no such block.
61
+ */
62
+ EnergyManagerSettingEnum["BatteryDischargeBlockAboveHomeConsumptionW"] = "battery-discharge-block-above-home-consumption-w";
53
63
  // ── Heat pump ──────────────────────────────────────────────────────────
54
64
  /** Whether the energy manager may steer the heat pump. */
55
65
  EnergyManagerSettingEnum["HeatpumpControl"] = "heatpump-control";
@@ -58,9 +68,19 @@ var EnergyManagerSettingEnum;
58
68
  EnergyManagerSettingEnum["HeatingRodControl"] = "heating-rod-control";
59
69
  /** What the heating rod is allowed to do — see {@link EnergyManagerHeatingRodModeEnum}. */
60
70
  EnergyManagerSettingEnum["HeatingRodMode"] = "heating-rod-mode";
71
+ /**
72
+ * The SoC floor, in %, down to which the house battery may feed the
73
+ * heating rod. Unset or `null` means the battery never feeds the rod.
74
+ */
75
+ EnergyManagerSettingEnum["HeatingRodBatteryDischargeSocLimitPercent"] = "heating-rod-battery-discharge-soc-limit-percent";
61
76
  // ── Charger ────────────────────────────────────────────────────────────
62
77
  /** Whether the energy manager may steer the wallbox. Gates the other charger settings. */
63
78
  EnergyManagerSettingEnum["ChargerControl"] = "charger-control";
79
+ /**
80
+ * Whether cost-optimised charging on a non-dynamic tariff places its grid
81
+ * energy in low-load hours (by EPEX spot) to relieve the grid.
82
+ */
83
+ EnergyManagerSettingEnum["GridOptimizedEvCharging"] = "grid-optimized-ev-charging";
64
84
  /**
65
85
  * The charge mode applied when a session starts without an explicit one
66
86
  * ({@link EnyoChargeModeEnum}).
@@ -204,7 +224,9 @@ var EnergyManagerHeatingRodModeEnum;
204
224
  (function (EnergyManagerHeatingRodModeEnum) {
205
225
  /**
206
226
  * Only ever run on PV surplus. The rod is a dump load for energy that would
207
- * otherwise be exported; it never causes an import.
227
+ * otherwise be exported; it never causes an import. With
228
+ * {@link EnergyManagerSettingValues.heatingRodBatteryDischargeSocLimitPercent}
229
+ * set, the battery above that floor counts as supply too.
208
230
  */
209
231
  EnergyManagerHeatingRodModeEnum["PvSurplusOnly"] = "pv-surplus-only";
210
232
  /**
@@ -216,7 +238,7 @@ var EnergyManagerHeatingRodModeEnum;
216
238
  /**
217
239
  * Which settings are gated by which, as data.
218
240
  *
219
- * Six of the ten settings only mean anything while another one holds a specific
241
+ * Fifteen of the nineteen settings only mean anything while another one holds a specific
220
242
  * value. Encoding that here rather than in prose gives the cockpit a single
221
243
  * source of truth for greying out dependent controls, and lets
222
244
  * `validateEnergyManagerSettingsState()` flag values that can never take effect.
@@ -250,10 +272,26 @@ exports.ENERGY_MANAGER_SETTING_DEPENDENCIES = {
250
272
  requires: EnergyManagerSettingEnum.BatteryEvDischargeMode,
251
273
  equals: EnergyManagerBatteryEvDischargeModeEnum.SocLimit,
252
274
  },
275
+ [EnergyManagerSettingEnum.BatteryPvPriorityTargetSocPercent]: {
276
+ requires: EnergyManagerSettingEnum.BatteryControl,
277
+ equals: true,
278
+ },
279
+ [EnergyManagerSettingEnum.BatteryDischargeBlockAboveHomeConsumptionW]: {
280
+ requires: EnergyManagerSettingEnum.BatteryControl,
281
+ equals: true,
282
+ },
253
283
  [EnergyManagerSettingEnum.HeatingRodMode]: {
254
284
  requires: EnergyManagerSettingEnum.HeatingRodControl,
255
285
  equals: true,
256
286
  },
287
+ [EnergyManagerSettingEnum.HeatingRodBatteryDischargeSocLimitPercent]: {
288
+ requires: EnergyManagerSettingEnum.HeatingRodControl,
289
+ equals: true,
290
+ },
291
+ [EnergyManagerSettingEnum.GridOptimizedEvCharging]: {
292
+ requires: EnergyManagerSettingEnum.ChargerControl,
293
+ equals: true,
294
+ },
257
295
  [EnergyManagerSettingEnum.DefaultChargeMode]: {
258
296
  requires: EnergyManagerSettingEnum.ChargerControl,
259
297
  equals: true,
@@ -289,10 +327,18 @@ exports.ENERGY_MANAGER_SETTING_VALUE_KEYS = {
289
327
  [EnergyManagerSettingEnum.BatteryEvDischargeMode]: ['batteryEvDischargeMode'],
290
328
  [EnergyManagerSettingEnum.BatteryEvDischargeFixedWh]: ['batteryEvDischargeFixedWh'],
291
329
  [EnergyManagerSettingEnum.BatteryEvDischargeSocLimitPercent]: ['batteryEvDischargeSocLimitPercent'],
330
+ [EnergyManagerSettingEnum.BatteryPvPriorityTargetSocPercent]: ['batteryPvPriorityTargetSocPercent'],
331
+ [EnergyManagerSettingEnum.BatteryDischargeBlockAboveHomeConsumptionW]: [
332
+ 'batteryDischargeBlockAboveHomeConsumptionW',
333
+ ],
292
334
  [EnergyManagerSettingEnum.HeatpumpControl]: ['heatpumpControl'],
293
335
  [EnergyManagerSettingEnum.HeatingRodControl]: ['heatingRodControl'],
294
336
  [EnergyManagerSettingEnum.HeatingRodMode]: ['heatingRodMode'],
337
+ [EnergyManagerSettingEnum.HeatingRodBatteryDischargeSocLimitPercent]: [
338
+ 'heatingRodBatteryDischargeSocLimitPercent',
339
+ ],
295
340
  [EnergyManagerSettingEnum.ChargerControl]: ['chargerControl'],
341
+ [EnergyManagerSettingEnum.GridOptimizedEvCharging]: ['gridOptimizedEvCharging'],
296
342
  [EnergyManagerSettingEnum.DefaultChargeMode]: ['defaultChargeMode'],
297
343
  [EnergyManagerSettingEnum.PriceLimitMode]: ['priceLimitMode'],
298
344
  [EnergyManagerSettingEnum.PriceLimitCtPerKwh]: ['priceLimitCtPerKwh'],