@enyo-energy/energy-app-sdk 1.32.0 → 1.34.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.
Files changed (30) hide show
  1. package/README.md +33 -2
  2. package/dist/cjs/implementations/heatpump/heatpump-control-validators.cjs +89 -1
  3. package/dist/cjs/implementations/heatpump/heatpump-control-validators.d.cts +55 -1
  4. package/dist/cjs/implementations/heatpump/heatpump-metadata-validators.cjs +57 -2
  5. package/dist/cjs/implementations/heatpump/heatpump-metadata-validators.d.cts +8 -2
  6. package/dist/cjs/integrations/heatpump-integration-energy-app.cjs +131 -3
  7. package/dist/cjs/integrations/heatpump-integration-energy-app.d.cts +82 -4
  8. package/dist/cjs/types/enyo-charger-appliance.d.cts +59 -0
  9. package/dist/cjs/types/enyo-data-bus-value.cjs +51 -2
  10. package/dist/cjs/types/enyo-data-bus-value.d.cts +162 -5
  11. package/dist/cjs/types/enyo-heatpump-appliance.cjs +33 -1
  12. package/dist/cjs/types/enyo-heatpump-appliance.d.cts +79 -1
  13. package/dist/cjs/types/enyo-vehicle.d.cts +39 -0
  14. package/dist/cjs/version.cjs +1 -1
  15. package/dist/cjs/version.d.cts +1 -1
  16. package/dist/implementations/heatpump/heatpump-control-validators.d.ts +55 -1
  17. package/dist/implementations/heatpump/heatpump-control-validators.js +88 -1
  18. package/dist/implementations/heatpump/heatpump-metadata-validators.d.ts +8 -2
  19. package/dist/implementations/heatpump/heatpump-metadata-validators.js +58 -3
  20. package/dist/integrations/heatpump-integration-energy-app.d.ts +82 -4
  21. package/dist/integrations/heatpump-integration-energy-app.js +132 -4
  22. package/dist/types/enyo-charger-appliance.d.ts +59 -0
  23. package/dist/types/enyo-data-bus-value.d.ts +162 -5
  24. package/dist/types/enyo-data-bus-value.js +51 -2
  25. package/dist/types/enyo-heatpump-appliance.d.ts +79 -1
  26. package/dist/types/enyo-heatpump-appliance.js +32 -0
  27. package/dist/types/enyo-vehicle.d.ts +39 -0
  28. package/dist/version.d.ts +1 -1
  29. package/dist/version.js +1 -1
  30. package/package.json +1 -1
package/README.md CHANGED
@@ -829,6 +829,9 @@ vehicle?.priceLimitMode; // how the ceiling is expressed, or none
829
829
  vehicle?.priceLimitCtPerKwh; // ct/kWh ceiling, under 'ct-per-kwh'
830
830
  vehicle?.priceLimitSharePercent; // cheapest n % of the day, under 'cheapest-share'
831
831
  vehicle?.immediateReserveKwh; // full-power reserve before any mode
832
+ vehicle?.minChargingCurrentAmpere; // lowest current per phase the car accepts
833
+ vehicle?.cannotBePaused; // true: never pause a started session
834
+ vehicle?.phaseSwitchDisabled; // true: never switch phases while charging
832
835
  vehicle?.departureTimeHHmm; // daily deadline, wall-clock "07:30"
833
836
  vehicle?.departureTimezone; // IANA zone the time is read in
834
837
 
@@ -3150,11 +3153,12 @@ Drives a heatpump. Manages building / DHW overheating commands and grid-power-av
3150
3153
 
3151
3154
  > **Preferred: control heatpumps with the V3 messages.** `HeatpumpControlV3` (power, power sources, which zones to heat now, until when), `HeatpumpControlCancelV3` (cancel a session) and `HeatpumpControlStatusV3` (progress) are the preferred way to control a heatpump — for energy managers sending commands and for integrations receiving them. Use them for all new work. `HeatpumpOverheatingV1`, `SetHeatpumpAvailablePowerV2` and `HeatpumpAvailablePowerAnnouncementV1` are deprecated and only remain for heatpumps that do not declare `ControlV3`. See [HeatpumpControlV3](#heatpumpcontrolv3). Likewise, a heatpump offers its flexibility with `HeatpumpFlexibilityAnnouncementV3` rather than the generic `ApplianceFlexibilityAnnouncementV1` / `V2` — see [HeatpumpFlexibilityAnnouncementV3](#heatpumpflexibilityannouncementv3).
3152
3155
 
3153
- - **Subscribed commands:** `HeatpumpControlV3`, `HeatpumpControlCancelV3`, `HeatpumpOverheatingV1` (deprecated), `HeatpumpAvailablePowerAnnouncementV1` (deprecated), `SetHeatpumpAvailablePowerV2` (deprecated), `SetHeatpumpRoomTemperatureV1`, `GridOperatorPowerLimitationV1` (broadcast)
3156
+ - **Subscribed commands:** `HeatpumpControlV3`, `HeatpumpControlCancelV3`, `HeatpumpProgramSwitchV3`, `HeatpumpOverheatingV1` (deprecated), `HeatpumpAvailablePowerAnnouncementV1` (deprecated), `SetHeatpumpAvailablePowerV2` (deprecated), `SetHeatpumpRoomTemperatureV1`, `GridOperatorPowerLimitationV1` (broadcast)
3154
3157
  - **Implement:** `handleHeatpumpOverheating`, `handleHeatpumpAvailablePowerAnnouncement`, `handleSetHeatpumpAvailablePower`, `handleGridOperatorPowerLimitation`
3155
3158
  - **Optional overrides:**
3156
3159
  - `handleSetHeatpumpRoomTemperature` — write a measured room temperature into a heating circuit. Answers `NotSupported` by default; override it when the heatpump declares `RoomTemperatureInput`.
3157
3160
  - `handleHeatpumpControl` — support `HeatpumpControlV3` (see below). Answers `NotSupported` by default; override it and declare `ControlV3`.
3161
+ - `handleHeatpumpProgramSwitch(request)` — switch a zone to comfort or eco, or back to its time program (see [HeatpumpProgramSwitchV3](#heatpumpprogramswitchv3)). Answers `NotSupported` by default; override it and declare `ProgramSwitch`.
3158
3162
  - `handleHeatpumpControlCancel` — withdraw a cancelled session on the device. By default `Accepted` for heatpumps declaring `ControlV3`, `NotSupported` otherwise.
3159
3163
  - `onHeatpumpControlEnded(applianceId, cause)` — return the heatpump to its own behaviour when a session ends; `cause` is `HeatpumpControlEndCauseEnum.Expired`, `Ended` or `Cancelled`. Does nothing by default.
3160
3164
  - `resolveHeatpumpMetadata(applianceId)` — the metadata actions are validated against. Defaults to the stored appliance's `heatpump` metadata.
@@ -3194,6 +3198,26 @@ One message per control session says **how much** power (`availablePowerW`), **f
3194
3198
  - DHW must stay within `domesticHotWater[].maxTemperatureC`: setpoint + `deltaK` above it is rejected.
3195
3199
  - When the session names an `offerId`, each action must fit the heatpump's current offer.
3196
3200
 
3201
+ #### HeatpumpProgramSwitchV3
3202
+
3203
+ Switches one heating circuit or DHW zone to its **comfort** or **eco** setpoint regardless of the time program, or back to the **program** — e.g. eco while electricity is expensive, comfort while there is PV surplus. The temperatures are the user's own; the energy manager chooses only which of them applies. It comes with a reason (`EnyoDataBusCommandReason`), e.g. `ElectricityPriceAboveThreshold` or `PvSurplusAvailable`.
3204
+
3205
+ ```typescript
3206
+ {
3207
+ target: EnyoHeatpumpControlTargetEnum.Room, // Room or Dhw
3208
+ index: 0,
3209
+ mode: EnyoHeatpumpProgramModeEnum.Eco, // Program | Comfort | Eco
3210
+ untilIso: '2026-10-18T17:30:00.000Z', // required for Comfort / Eco, at most 30 min ahead
3211
+ reason: { type: EnyoDataBusCommandReasonTypeEnum.ElectricityPriceAboveThreshold },
3212
+ }
3213
+ ```
3214
+
3215
+ - **Only to heatpumps declaring `ProgramSwitch`**, and only with a mode the zone lists in `heatingCircuits[].supportedProgramModes` / `domesticHotWater[].supportedProgramModes`. `programModeTemperaturesC` (`{comfort: 22, eco: 20}`) tells the energy manager what eco means at this house, and `activeProgramMode` what the zone runs in right now — so it notices a manual change.
3216
+ - **One zone per message**, answered with a plain `CommandAcknowledgeV1`.
3217
+ - **Ending and safety net.** `mode: Program` ends a switch (idempotent). A switch to comfort or eco holds until `untilIso` (at most `HEATPUMP_PROGRAM_SWITCH_MAX_MINUTES` = 30 min ahead); the energy manager re-sends about every 15 min while it should hold. When `untilIso` passes without a newer message, the base class calls `handleHeatpumpProgramSwitch` with `mode: Program` and `cause: HeatpumpProgramSwitchCauseEnum.Expired` — so after a restart of either side a zone is back on its program within 30 min, and the app stores nothing.
3218
+ - **Alongside `HeatpumpControlV3`.** The two run side by side: the mode sets the base, an action raises above it (eco 20 °C + room action +1 K = 21 °C).
3219
+ - **Validation** (run by the base class before the handler, or `validateHeatpumpProgramSwitch(data, metadata)` directly): target `Room` / `Dhw`, a known mode, a valid `untilIso` for comfort / eco (`Rejected`); `ProgramSwitch` declared and the mode listed for the zone (`NotSupported`); a known zone index (`Rejected`).
3220
+
3197
3221
  #### HeatpumpFlexibilityAnnouncementV3
3198
3222
 
3199
3223
  The heatpump's offer, in the vocabulary of `HeatpumpControlV3`: per zone (`target` + `index`), how far it can be raised right now (`maxDeltaK` above `setpointC`, room in Kelvin of room temperature) and the smallest raise that actually starts it (`minDeltaK`, e.g. the remaining hysteresis), with which heat source at what power (`heatSources`: compressor and heating rod as separate entries, each with a `power` band and an optional `estimatedElectricalKwh` for the full raise), and when (`earliestStartIso` / `latestStartIso`, `minDurationMinutes`). An optional `required: {deltaK, byIso}` turns a "may" into a "must, by then" — e.g. hot water dropping below comfort at 18:00. Declare `FlexibilityAnnouncementV3` on the heatpump; for such heatpumps it supersedes the generic `ApplianceFlexibilityAnnouncementV1` / `V2`.
@@ -3226,6 +3250,7 @@ Beyond tanks and circuits, the heatpump metadata (`EnyoHeatpumpApplianceMetadata
3226
3250
  |---|---|
3227
3251
  | `HeatingCurve` | `heatingCircuits[].heatingCurve` — outdoor → flow temperature `points` (vendor-neutral, linearly interpolated), plus raw `slope` / `parallelShiftK`, min / max flow and an optional cooling curve |
3228
3252
  | `TimeProgram` | `heatingCircuits[].timeProgram` and `domesticHotWater[].timeProgram` — weekly periods (`daysOfWeek` 0 = Sunday, local `HH:mm` times, may wrap past midnight) switching between `Comfort` / `Reduced` / `Off` / `Boost` |
3253
+ | `ProgramSwitch` | `heatingCircuits[].supportedProgramModes` / `programModeTemperaturesC` / `activeProgramMode` and the same on `domesticHotWater[]` — which zones can be switched to comfort / eco with `HeatpumpProgramSwitchV3`, at which setpoints, and the mode they run in now |
3229
3254
  | `RoomTemperature` | `heatingCircuits[].roomTemperatureC` in `HeatpumpTemperaturesUpdateV1`; where it is measured goes in `heatingCircuits[].roomTemperatureSource` (`None` / `Internal` / `External`) and, for an enyo sensor, `roomTemperatureSensor` |
3230
3255
  | `RoomTemperatureInput` | The heatpump accepts measured room temperatures via `SetHeatpumpRoomTemperatureV1` |
3231
3256
  | `PvSurplusSignal` | The heatpump takes `availablePowerW` of `HeatpumpControlV3` as a PV surplus signal (e.g. Lambda register 102). Supersedes the deprecated `AvailablePowerAnnouncement` |
@@ -4317,7 +4342,7 @@ Each event carries the **complete** picture of the slot — render it as it arri
4317
4342
 
4318
4343
  New reason types came with this surface, so a skipped row can say *why* instead of falling back to a generic "scheduled optimization": `SessionComplete`, `AppliancePaused`, `NothingConnected`, `DeadlinePassed`, `WaitingForCheaperSlot`, `AboveOwnPriceLimit`, `BelowMinPower`, `OtherApplianceTurn`, `SupplyExhausted`, `OutsideSchedule` — grouped by the new `SessionState` and `Contention` reason categories. `ApplianceInitiatedDraw` and `PowerOffered` came with the waterfall states above.
4319
4344
 
4320
- **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. The car's side of the same decision is stated on the charger's command with `EvChargingFromHomeBattery` (also category `BatteryState`): `powerW` is the battery's share of the charging setpoint (never the whole setpoint), `socPercent` the battery fleet's SoC, `context.batteryToEv` the owner's mode and the session's `socLimitPercent` / `allowanceWh` / `remainingWh` (never `trigger` or `probe`), and, under `Intelligent` only, `electricityPricePerKwh` is what the stored energy costs.
4345
+ **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`, `NightReserve`), `socLimitPercent`, `allowanceWh`, `nightReserveWh` and `remainingWh`. Under `Intelligent`, `NightReserve` says the battery is held because only the night reserve is left — the house's load from dusk until PV covers it again — with `nightReserveWh` the energy kept, e.g. "The rest of the home battery (8.3 kWh) is kept for the house tonight, until the sun is back." 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. The car's side of the same decision is stated on the charger's command with `EvChargingFromHomeBattery` (also category `BatteryState`): `powerW` is the battery's share of the charging setpoint (never the whole setpoint), `socPercent` the battery fleet's SoC, `context.batteryToEv` the owner's mode and the session's `socLimitPercent` / `allowanceWh` / `remainingWh` (never `trigger` or `probe`), and, under `Intelligent` only, `electricityPricePerKwh` is what the stored energy costs.
4321
4346
 
4322
4347
  **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 %)."
4323
4348
 
@@ -4327,6 +4352,10 @@ New reason types came with this surface, so a skipped row can say *why* instead
4327
4352
  - `ElectricityPriceBelowThreshold` on a grid charge — the buy and the peak it is for: "Charging at 33.6 ct for the 17:00 peak at 43.7 ct."
4328
4353
  - `ScheduledOptimization` on a price hold (`Discharge 0`) — `sellStartIso` / `sellPricePerKwh`: "Stored energy kept for 17:00 (43.7 ct)." Not `ElectricityPriceBelowThreshold`, which would say power is cheap for a command that stops the battery.
4329
4354
 
4355
+ **Not enough power to start.** `BelowMinPower` (category `Contention`) carries `powerW` for the power that was available and `minPowerW` for the minimum the appliance needs to start — e.g. "Only 4.1 kW left — needs at least 4.2 kW to start."
4356
+
4357
+ **Left to its own control.** When the energy manager deliberately does not steer an appliance — e.g. a heat pump left alone at night on a static tariff — it states `SelfManaged` (category `Other`). This is the manager's choice not to steer, not the appliance overriding a plan (`ApplianceInitiatedDraw`) or declining power (`AppliancePaused`).
4358
+
4330
4359
  **Installation limits.** Power capped by a limit the owner or installer set — not the grid operator — carries `InstallationLimit` (category `Contention`) with `powerW` set to the ceiling and `context.installationLimit` = `{source, limitW}`, where `source` is `EnyoInstallationLimitSourceEnum.DesiredGridLimit` (`desiredKwLimit`) or `ChargerLimit` (`chargerLimitW`). `limitW` is in **W** even for the desired limit, which is configured in kW. `GridOperatorPowerLimitationActive` stays reserved for limits the grid operator imposed.
4331
4360
 
4332
4361
  **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."
@@ -4337,6 +4366,8 @@ New reason types came with this surface, so a skipped row can say *why* instead
4337
4366
 
4338
4367
  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."
4339
4368
 
4369
+ A session kept running because it **must not be paused** at all — the charger's or the car's `cannotBePaused` is `true` — has its own type, `CannotBePaused` (category `DeviceProtection`), with `powerW` set to the minimum it is held at and no `context.switching` — e.g. "Kept charging at the minimum of 1.4 kW — this car cannot pause charging." Do not send it as `DeviceProtection` / `KeptOn`: that reads "Kept running for its minimum run time", which is untrue for a hold that lasts until the session is complete.
4370
+
4340
4371
  **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`.
4341
4372
 
4342
4373
  ## Dynamic Grid Fees & Tariff Bonuses
@@ -1,9 +1,10 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.HEATPUMP_CONTROL_TARGET_FEATURE = void 0;
3
+ exports.HEATPUMP_PROGRAM_SWITCH_CLOCK_TOLERANCE_SECONDS = exports.HEATPUMP_PROGRAM_SWITCH_MAX_MINUTES = exports.HEATPUMP_CONTROL_TARGET_FEATURE = void 0;
4
4
  exports.isPvOnlyPowerSources = isPvOnlyPowerSources;
5
5
  exports.validateHeatpumpControlAction = validateHeatpumpControlAction;
6
6
  exports.computeHeatpumpSetpointRaise = computeHeatpumpSetpointRaise;
7
+ exports.validateHeatpumpProgramSwitch = validateHeatpumpProgramSwitch;
7
8
  const enyo_data_bus_value_js_1 = require("../../types/enyo-data-bus-value.cjs");
8
9
  const enyo_heatpump_appliance_js_1 = require("../../types/enyo-heatpump-appliance.cjs");
9
10
  /**
@@ -196,3 +197,90 @@ function computeHeatpumpSetpointRaise(input) {
196
197
  }
197
198
  return { ok: true, targetTemperatureC: target, appliedDeltaK: target - setpointC };
198
199
  }
200
+ /**
201
+ * How far ahead `untilIso` of a `HeatpumpProgramSwitchV3` to `Comfort` or
202
+ * `Eco` may lie, in minutes. Bounds how long a zone stays switched when the
203
+ * energy manager stops re-sending (e.g. after a restart).
204
+ */
205
+ exports.HEATPUMP_PROGRAM_SWITCH_MAX_MINUTES = 30;
206
+ /**
207
+ * Clock-skew tolerance on top of {@link HEATPUMP_PROGRAM_SWITCH_MAX_MINUTES},
208
+ * in seconds, so an `untilIso` sent exactly 30 min ahead is not rejected
209
+ * because the two clocks differ slightly.
210
+ */
211
+ exports.HEATPUMP_PROGRAM_SWITCH_CLOCK_TOLERANCE_SECONDS = 60;
212
+ /**
213
+ * Validates a `HeatpumpProgramSwitchV3` against the heatpump's metadata and
214
+ * returns the answer it deserves.
215
+ *
216
+ * Used by `HeatpumpIntegrationEnergyApp` before it calls
217
+ * `handleHeatpumpProgramSwitch`; an integration handling the message itself
218
+ * can call it too.
219
+ *
220
+ * Rules, in order — the first that fails decides:
221
+ * 1. `target` is `Room` or `Dhw`, and `mode` is an
222
+ * {@link EnyoHeatpumpProgramModeEnum} member (**Rejected**).
223
+ * 2. For `Comfort` / `Eco`: `untilIso` is an ISO 8601 timestamp in the future
224
+ * and at most {@link HEATPUMP_PROGRAM_SWITCH_MAX_MINUTES} minutes ahead
225
+ * (plus {@link HEATPUMP_PROGRAM_SWITCH_CLOCK_TOLERANCE_SECONDS})
226
+ * (**Rejected**).
227
+ * 3. The heatpump does not declare `ProgramSwitch` (**NotSupported**).
228
+ * 4. The metadata lists zones for the target but none with the message's
229
+ * `index` (**Rejected**).
230
+ * 5. For `Comfort` / `Eco`: the zone does not list the mode in
231
+ * `supportedProgramModes` (**NotSupported**).
232
+ *
233
+ * Without metadata only rules 1 and 2 can be checked.
234
+ *
235
+ * @param data - The message's data.
236
+ * @param metadata - The heatpump's metadata, if known.
237
+ * @param nowMs - The current time in epoch milliseconds; injectable for tests.
238
+ * @returns The answer: `Accepted` when every rule holds.
239
+ *
240
+ * @example
241
+ * ```typescript
242
+ * const result = validateHeatpumpProgramSwitch(message.data, appliance.heatpump);
243
+ * if (result.answer !== EnyoCommandAcknowledgeAnswerEnum.Accepted) console.warn(result.rejectionReason);
244
+ * ```
245
+ */
246
+ function validateHeatpumpProgramSwitch(data, metadata, nowMs = Date.now()) {
247
+ const rejected = (rejectionReason) => ({ answer: enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Rejected, rejectionReason });
248
+ const notSupported = (rejectionReason) => ({ answer: enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.NotSupported, rejectionReason });
249
+ if (data.target !== enyo_data_bus_value_js_1.EnyoHeatpumpControlTargetEnum.Room && data.target !== enyo_data_bus_value_js_1.EnyoHeatpumpControlTargetEnum.Dhw) {
250
+ return rejected(`Only room and dhw zones can be switched; got "${data.target}".`);
251
+ }
252
+ if (!Object.values(enyo_heatpump_appliance_js_1.EnyoHeatpumpProgramModeEnum).includes(data.mode)) {
253
+ return rejected(`Unknown program mode "${data.mode}".`);
254
+ }
255
+ const isOverride = data.mode !== enyo_heatpump_appliance_js_1.EnyoHeatpumpProgramModeEnum.Program;
256
+ if (isOverride) {
257
+ const untilMs = data.untilIso !== undefined ? Date.parse(data.untilIso) : Number.NaN;
258
+ if (!Number.isFinite(untilMs)) {
259
+ return rejected(`A switch to ${data.mode} needs untilIso as an ISO 8601 timestamp; got ${data.untilIso}.`);
260
+ }
261
+ if (untilMs <= nowMs) {
262
+ return rejected(`untilIso ${data.untilIso} has already passed.`);
263
+ }
264
+ const maxMs = nowMs + exports.HEATPUMP_PROGRAM_SWITCH_MAX_MINUTES * 60_000 + exports.HEATPUMP_PROGRAM_SWITCH_CLOCK_TOLERANCE_SECONDS * 1000;
265
+ if (untilMs > maxMs) {
266
+ return rejected(`untilIso ${data.untilIso} is more than ${exports.HEATPUMP_PROGRAM_SWITCH_MAX_MINUTES} min ahead; ` +
267
+ 're-send the switch instead of sending a far-off untilIso.');
268
+ }
269
+ }
270
+ if (!metadata)
271
+ return { answer: enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Accepted };
272
+ if (!(metadata.availableFeatures ?? []).includes(enyo_heatpump_appliance_js_1.EnyoHeatpumpApplianceAvailableFeaturesEnum.ProgramSwitch)) {
273
+ return notSupported('The heatpump does not support program switches (no ProgramSwitch).');
274
+ }
275
+ const zones = data.target === enyo_data_bus_value_js_1.EnyoHeatpumpControlTargetEnum.Dhw ? metadata.domesticHotWater : metadata.heatingCircuits;
276
+ const zone = zones && zones.length > 0
277
+ ? (data.index === undefined ? zones[0] : zones.find((z) => z.index === data.index))
278
+ : undefined;
279
+ if (zones && zones.length > 0 && !zone) {
280
+ return rejected(`No ${data.target} zone with index ${data.index}.`);
281
+ }
282
+ if (isOverride && !(zone?.supportedProgramModes ?? []).includes(data.mode)) {
283
+ return notSupported(`The ${data.target} zone ${zone?.index ?? data.index ?? 0} cannot be switched to ${data.mode}.`);
284
+ }
285
+ return { answer: enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Accepted };
286
+ }
@@ -1,4 +1,4 @@
1
- import { EnyoCommandAcknowledgeActionResult, EnyoDataBusHeatpumpFlexibilityAnnouncementV3, EnyoHeatpumpControlAction, EnyoHeatpumpControlTargetEnum, EnyoPowerSourceShare } from '../../types/enyo-data-bus-value.cjs';
1
+ import { EnyoCommandAcknowledgeActionResult, EnyoCommandAcknowledgeAnswerEnum, EnyoDataBusHeatpumpFlexibilityAnnouncementV3, EnyoDataBusHeatpumpProgramSwitchV3, EnyoHeatpumpControlAction, EnyoHeatpumpControlTargetEnum, EnyoPowerSourceShare } from '../../types/enyo-data-bus-value.cjs';
2
2
  import { EnyoHeatpumpApplianceAvailableFeaturesEnum, EnyoHeatpumpApplianceMetadata } from '../../types/enyo-heatpump-appliance.cjs';
3
3
  /**
4
4
  * The overheating feature each `HeatpumpControlV3` target needs — and that a
@@ -121,3 +121,57 @@ export type HeatpumpSetpointRaiseResult = {
121
121
  * ```
122
122
  */
123
123
  export declare function computeHeatpumpSetpointRaise(input: HeatpumpSetpointRaiseInput): HeatpumpSetpointRaiseResult;
124
+ /**
125
+ * How far ahead `untilIso` of a `HeatpumpProgramSwitchV3` to `Comfort` or
126
+ * `Eco` may lie, in minutes. Bounds how long a zone stays switched when the
127
+ * energy manager stops re-sending (e.g. after a restart).
128
+ */
129
+ export declare const HEATPUMP_PROGRAM_SWITCH_MAX_MINUTES = 30;
130
+ /**
131
+ * Clock-skew tolerance on top of {@link HEATPUMP_PROGRAM_SWITCH_MAX_MINUTES},
132
+ * in seconds, so an `untilIso` sent exactly 30 min ahead is not rejected
133
+ * because the two clocks differ slightly.
134
+ */
135
+ export declare const HEATPUMP_PROGRAM_SWITCH_CLOCK_TOLERANCE_SECONDS = 60;
136
+ /** The answer {@link validateHeatpumpProgramSwitch} gives a `HeatpumpProgramSwitchV3`. */
137
+ export interface HeatpumpProgramSwitchValidationResult {
138
+ /** `Accepted` when every rule holds. */
139
+ answer: EnyoCommandAcknowledgeAnswerEnum;
140
+ /** Why the switch was rejected or is not supported. */
141
+ rejectionReason?: string;
142
+ }
143
+ /**
144
+ * Validates a `HeatpumpProgramSwitchV3` against the heatpump's metadata and
145
+ * returns the answer it deserves.
146
+ *
147
+ * Used by `HeatpumpIntegrationEnergyApp` before it calls
148
+ * `handleHeatpumpProgramSwitch`; an integration handling the message itself
149
+ * can call it too.
150
+ *
151
+ * Rules, in order — the first that fails decides:
152
+ * 1. `target` is `Room` or `Dhw`, and `mode` is an
153
+ * {@link EnyoHeatpumpProgramModeEnum} member (**Rejected**).
154
+ * 2. For `Comfort` / `Eco`: `untilIso` is an ISO 8601 timestamp in the future
155
+ * and at most {@link HEATPUMP_PROGRAM_SWITCH_MAX_MINUTES} minutes ahead
156
+ * (plus {@link HEATPUMP_PROGRAM_SWITCH_CLOCK_TOLERANCE_SECONDS})
157
+ * (**Rejected**).
158
+ * 3. The heatpump does not declare `ProgramSwitch` (**NotSupported**).
159
+ * 4. The metadata lists zones for the target but none with the message's
160
+ * `index` (**Rejected**).
161
+ * 5. For `Comfort` / `Eco`: the zone does not list the mode in
162
+ * `supportedProgramModes` (**NotSupported**).
163
+ *
164
+ * Without metadata only rules 1 and 2 can be checked.
165
+ *
166
+ * @param data - The message's data.
167
+ * @param metadata - The heatpump's metadata, if known.
168
+ * @param nowMs - The current time in epoch milliseconds; injectable for tests.
169
+ * @returns The answer: `Accepted` when every rule holds.
170
+ *
171
+ * @example
172
+ * ```typescript
173
+ * const result = validateHeatpumpProgramSwitch(message.data, appliance.heatpump);
174
+ * if (result.answer !== EnyoCommandAcknowledgeAnswerEnum.Accepted) console.warn(result.rejectionReason);
175
+ * ```
176
+ */
177
+ export declare function validateHeatpumpProgramSwitch(data: EnyoDataBusHeatpumpProgramSwitchV3['data'], metadata: EnyoHeatpumpApplianceMetadata | undefined, nowMs?: number): HeatpumpProgramSwitchValidationResult;
@@ -2,8 +2,8 @@
2
2
  /**
3
3
  * Client-side validation for the advanced heatpump metadata an integration
4
4
  * publishes: heating curves ({@link EnyoHeatpumpApplianceHeatingCurve}), time
5
- * programs ({@link EnyoHeatpumpTimeProgram}) and the room-temperature wiring of
6
- * heating circuits.
5
+ * programs ({@link EnyoHeatpumpTimeProgram}), the room-temperature wiring of
6
+ * heating circuits and the program-switch modes of circuits and DHW zones.
7
7
  *
8
8
  * None of these shapes can be checked at the type level — a curve with one
9
9
  * point, an unsorted curve, `'25:00'` as a switching time or a period on day
@@ -45,6 +45,41 @@ const MINUTES_PER_DAY = 24 * 60;
45
45
  const MINUTES_PER_WEEK = 7 * MINUTES_PER_DAY;
46
46
  const TIME_OF_DAY = /^([01]\d|2[0-3]):([0-5]\d)$/;
47
47
  const LEVELS = new Set(Object.values(enyo_heatpump_appliance_js_1.EnyoHeatpumpTimeProgramLevelEnum));
48
+ const PROGRAM_MODES = new Set(Object.values(enyo_heatpump_appliance_js_1.EnyoHeatpumpProgramModeEnum));
49
+ const OVERRIDE_PROGRAM_MODES = new Set([enyo_heatpump_appliance_js_1.EnyoHeatpumpProgramModeEnum.Comfort, enyo_heatpump_appliance_js_1.EnyoHeatpumpProgramModeEnum.Eco]);
50
+ /**
51
+ * Validates the program-switch fields of one heating circuit or DHW zone
52
+ * (`supportedProgramModes`, `programModeTemperaturesC`, `activeProgramMode`)
53
+ * and appends the problems to `errors` / `warnings`.
54
+ *
55
+ * @param zone - The heating circuit or DHW zone.
56
+ * @param prefix - Path prefix for the messages, e.g. `heatingCircuits[index=0]`.
57
+ * @param errors - Receives blocking problems.
58
+ * @param warnings - Receives advisory problems.
59
+ */
60
+ function checkProgramModes(zone, prefix, errors, warnings) {
61
+ for (const mode of zone.supportedProgramModes ?? []) {
62
+ if (!OVERRIDE_PROGRAM_MODES.has(mode)) {
63
+ errors.push(`${prefix}: supportedProgramModes may only list comfort and eco; got "${mode}"`);
64
+ }
65
+ }
66
+ const temperatures = zone.programModeTemperaturesC ?? {};
67
+ for (const [mode, temperatureC] of Object.entries(temperatures)) {
68
+ if (!OVERRIDE_PROGRAM_MODES.has(mode)) {
69
+ errors.push(`${prefix}: programModeTemperaturesC may only have comfort and eco; got "${mode}"`);
70
+ }
71
+ else if (!isFiniteNumber(temperatureC)) {
72
+ errors.push(`${prefix}: programModeTemperaturesC.${mode} must be a finite number`);
73
+ }
74
+ }
75
+ const { comfort, eco } = temperatures;
76
+ if (isFiniteNumber(comfort) && isFiniteNumber(eco) && eco > comfort) {
77
+ warnings.push(`${prefix}: programModeTemperaturesC.eco ${eco} °C is above comfort ${comfort} °C`);
78
+ }
79
+ if (zone.activeProgramMode !== undefined && !PROGRAM_MODES.has(zone.activeProgramMode)) {
80
+ errors.push(`${prefix}: activeProgramMode "${zone.activeProgramMode}" is not a valid value`);
81
+ }
82
+ }
48
83
  /**
49
84
  * Parses an `HH:mm` time of day into minutes since midnight.
50
85
  *
@@ -289,6 +324,12 @@ function assertValidHeatpumpTimeProgram(program) {
289
324
  * disagree, so pre-heating may be planned where the heatpump does nothing.
290
325
  * - Error: a circuit's `heatingControlReference` is not an
291
326
  * `EnyoHeatpumpHeatingControlReferenceEnum` member.
327
+ * - Error: a circuit's or DHW zone's `supportedProgramModes` or
328
+ * `programModeTemperaturesC` name a mode other than comfort and eco, a
329
+ * temperature is not finite, or `activeProgramMode` is not an
330
+ * `EnyoHeatpumpProgramModeEnum` member. Warning: eco is above comfort.
331
+ * - Warning: `ProgramSwitch` is declared but no zone lists
332
+ * `supportedProgramModes`, or a zone lists them without `ProgramSwitch`.
292
333
  *
293
334
  * @param metadata - The heatpump metadata to validate.
294
335
  * @returns The validation result.
@@ -325,6 +366,20 @@ function validateHeatpumpAdvancedMetadata(metadata) {
325
366
  warnings.push(`${v3} is declared without any overheating feature — no action could ever be taken`);
326
367
  }
327
368
  }
369
+ const switchableZones = [
370
+ ...circuits.map((c) => ({ zone: c, prefix: `heatingCircuits[index=${c.index}]` })),
371
+ ...dhwZones.map((z) => ({ zone: z, prefix: `domesticHotWater[index=${z.index}]` })),
372
+ ];
373
+ const declaresProgramSwitch = features.includes(enyo_heatpump_appliance_js_1.EnyoHeatpumpApplianceAvailableFeaturesEnum.ProgramSwitch);
374
+ for (const { zone, prefix } of switchableZones) {
375
+ checkProgramModes(zone, prefix, errors, warnings);
376
+ if (!declaresProgramSwitch && (zone.supportedProgramModes ?? []).length > 0) {
377
+ warnings.push(`${prefix}: supportedProgramModes are listed but the heatpump does not declare ProgramSwitch`);
378
+ }
379
+ }
380
+ if (declaresProgramSwitch && !switchableZones.some(({ zone }) => (zone.supportedProgramModes ?? []).length > 0)) {
381
+ warnings.push('ProgramSwitch is declared but no zone lists supportedProgramModes — no switch could ever be accepted');
382
+ }
328
383
  for (const circuit of circuits) {
329
384
  const prefix = `heatingCircuits[index=${circuit.index}]`;
330
385
  if (circuit.heatingCurve)
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Client-side validation for the advanced heatpump metadata an integration
3
3
  * publishes: heating curves ({@link EnyoHeatpumpApplianceHeatingCurve}), time
4
- * programs ({@link EnyoHeatpumpTimeProgram}) and the room-temperature wiring of
5
- * heating circuits.
4
+ * programs ({@link EnyoHeatpumpTimeProgram}), the room-temperature wiring of
5
+ * heating circuits and the program-switch modes of circuits and DHW zones.
6
6
  *
7
7
  * None of these shapes can be checked at the type level — a curve with one
8
8
  * point, an unsorted curve, `'25:00'` as a switching time or a period on day
@@ -107,6 +107,12 @@ export declare function assertValidHeatpumpTimeProgram(program: EnyoHeatpumpTime
107
107
  * disagree, so pre-heating may be planned where the heatpump does nothing.
108
108
  * - Error: a circuit's `heatingControlReference` is not an
109
109
  * `EnyoHeatpumpHeatingControlReferenceEnum` member.
110
+ * - Error: a circuit's or DHW zone's `supportedProgramModes` or
111
+ * `programModeTemperaturesC` name a mode other than comfort and eco, a
112
+ * temperature is not finite, or `activeProgramMode` is not an
113
+ * `EnyoHeatpumpProgramModeEnum` member. Warning: eco is above comfort.
114
+ * - Warning: `ProgramSwitch` is declared but no zone lists
115
+ * `supportedProgramModes`, or a zone lists them without `ProgramSwitch`.
110
116
  *
111
117
  * @param metadata - The heatpump metadata to validate.
112
118
  * @returns The validation result.
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.HeatpumpIntegrationEnergyApp = exports.HEATPUMP_FLEXIBILITY_DEFAULT_REPEAT_EVERY_SECONDS = exports.HEATPUMP_FLEXIBILITY_DEFAULT_VALID_FOR_SECONDS = void 0;
3
+ exports.HeatpumpIntegrationEnergyApp = exports.HeatpumpProgramSwitchCauseEnum = exports.HEATPUMP_FLEXIBILITY_DEFAULT_REPEAT_EVERY_SECONDS = exports.HEATPUMP_FLEXIBILITY_DEFAULT_VALID_FOR_SECONDS = void 0;
4
4
  exports.combineHeatpumpControlAnswers = combineHeatpumpControlAnswers;
5
5
  const integration_energy_app_js_1 = require("./integration-energy-app.cjs");
6
6
  const enyo_appliance_js_1 = require("../types/enyo-appliance.cjs");
@@ -13,6 +13,20 @@ const heatpump_control_session_js_1 = require("../implementations/heatpump/heatp
13
13
  exports.HEATPUMP_FLEXIBILITY_DEFAULT_VALID_FOR_SECONDS = 900;
14
14
  /** Default interval at which a published heatpump flexibility offer is repeated, in seconds. */
15
15
  exports.HEATPUMP_FLEXIBILITY_DEFAULT_REPEAT_EVERY_SECONDS = 300;
16
+ /**
17
+ * Why {@link HeatpumpIntegrationEnergyApp.handleHeatpumpProgramSwitch} is called.
18
+ */
19
+ var HeatpumpProgramSwitchCauseEnum;
20
+ (function (HeatpumpProgramSwitchCauseEnum) {
21
+ /** The energy manager sent a `HeatpumpProgramSwitchV3`. */
22
+ HeatpumpProgramSwitchCauseEnum["Command"] = "command";
23
+ /**
24
+ * A switch to comfort or eco reached its `untilIso` without a newer
25
+ * message; the base class switches the zone back to
26
+ * {@link EnyoHeatpumpProgramModeEnum.Program}.
27
+ */
28
+ HeatpumpProgramSwitchCauseEnum["Expired"] = "expired";
29
+ })(HeatpumpProgramSwitchCauseEnum || (exports.HeatpumpProgramSwitchCauseEnum = HeatpumpProgramSwitchCauseEnum = {}));
16
30
  /**
17
31
  * Combines the answers of a `HeatpumpControlV3` session — the power and each
18
32
  * action — into the session's top-level answer: `Accepted` as soon as one part
@@ -43,6 +57,12 @@ function combineHeatpumpControlAnswers(answers) {
43
57
  * and calls {@link onHeatpumpControlEnded} when it ends.
44
58
  * - `HeatpumpControlCancelV3` — cancel one `HeatpumpControlV3` session right
45
59
  * away (optional: override {@link handleHeatpumpControlCancel}).
60
+ * - `HeatpumpProgramSwitchV3` — switch a heating circuit or DHW zone to its
61
+ * comfort or eco setpoint, or back to its time program (optional: override
62
+ * {@link handleHeatpumpProgramSwitch} and declare
63
+ * {@link EnyoHeatpumpApplianceAvailableFeaturesEnum.ProgramSwitch}). The
64
+ * base class validates it and switches the zone back when `untilIso`
65
+ * passes.
46
66
  * - `HeatpumpOverheatingV1` — overheating commands (room / buffer tank / DHW).
47
67
  * Deprecated in favour of `HeatpumpControlV3`.
48
68
  * - `HeatpumpAvailablePowerAnnouncementV1` — available power announcements.
@@ -92,6 +112,8 @@ function combineHeatpumpControlAnswers(answers) {
92
112
  class HeatpumpIntegrationEnergyApp extends integration_energy_app_js_1.IntegrationEnergyApp {
93
113
  heatpumpFlexibilityOffers = new Map();
94
114
  heatpumpControlSessions = new heatpump_control_session_js_1.HeatpumpControlSessionTracker((applianceId) => void this.notifyHeatpumpControlEnded(applianceId, heatpump_control_session_js_1.HeatpumpControlEndCauseEnum.Expired));
115
+ /** Expiry timers of the zones currently switched to comfort or eco, by {@link heatpumpProgramSwitchKey}. */
116
+ heatpumpProgramSwitchTimers = new Map();
95
117
  /**
96
118
  * @param options - Standard integration options (see {@link IntegrationEnergyAppOptions}).
97
119
  */
@@ -108,15 +130,21 @@ class HeatpumpIntegrationEnergyApp extends integration_energy_app_js_1.Integrati
108
130
  this.registerCommandHandler(enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.SetHeatpumpRoomTemperatureV1, (msg) => this.handleSetHeatpumpRoomTemperature(msg));
109
131
  this.registerCommandHandler(enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.HeatpumpControlV3, (msg) => this.dispatchHeatpumpControl(msg));
110
132
  this.registerCommandHandler(enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.HeatpumpControlCancelV3, (msg) => this.dispatchHeatpumpControlCancel(msg));
133
+ this.registerCommandHandler(enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.HeatpumpProgramSwitchV3, (msg) => this.dispatchHeatpumpProgramSwitch(msg));
111
134
  }
112
135
  /**
113
136
  * Stops the integration, drops every running `HeatpumpControlV3` session
114
- * and its expiry timer, and stops repeating published flexibility offers.
115
- * The offers already sent lapse at their `validUntilIso`.
137
+ * and its expiry timer, drops the expiry timers of program switches, and
138
+ * stops repeating published flexibility offers. The offers already sent
139
+ * lapse at their `validUntilIso`. Zones switched to comfort or eco are not
140
+ * switched back on stop.
116
141
  */
117
142
  stop() {
118
143
  super.stop();
119
144
  this.heatpumpControlSessions.dispose();
145
+ for (const timer of this.heatpumpProgramSwitchTimers.values())
146
+ clearTimeout(timer);
147
+ this.heatpumpProgramSwitchTimers.clear();
120
148
  for (const offer of this.heatpumpFlexibilityOffers.values()) {
121
149
  if (offer.timer !== undefined)
122
150
  clearInterval(offer.timer);
@@ -280,6 +308,43 @@ class HeatpumpIntegrationEnergyApp extends integration_energy_app_js_1.Integrati
280
308
  }
281
309
  return { answer: enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Accepted };
282
310
  }
311
+ /**
312
+ * Switches one zone of the heatpump to a mode — for a
313
+ * `HeatpumpProgramSwitchV3` from the energy manager
314
+ * ({@link HeatpumpProgramSwitchCauseEnum.Command}), and back to
315
+ * {@link EnyoHeatpumpProgramModeEnum.Program} when such a switch reached
316
+ * its `untilIso` without a newer message
317
+ * ({@link HeatpumpProgramSwitchCauseEnum.Expired}). Answers `NotSupported`
318
+ * by default. Override it, declare
319
+ * {@link EnyoHeatpumpApplianceAvailableFeaturesEnum.ProgramSwitch}, and
320
+ * list the zone's `supportedProgramModes` to support program switches.
321
+ *
322
+ * What an implementation must do:
323
+ * - `Comfort` / `Eco`: run the zone at the user's comfort or eco setpoint,
324
+ * regardless of the time program — never a temperature of its own.
325
+ * - `Program`: return the zone to its time program and undo whatever the
326
+ * switch wrote. Must be idempotent: it is also called when nothing is
327
+ * switched.
328
+ * - The same mode arrives again about every 15 min as the energy manager
329
+ * renews the switch; skip the device write when nothing changed.
330
+ *
331
+ * The base class has already validated the request with
332
+ * {@link validateHeatpumpProgramSwitch} and keeps the expiry timer: it is
333
+ * armed when a switch to `Comfort` / `Eco` is answered `Accepted`, renewed
334
+ * by every further accepted switch, and cleared by an accepted `Program`.
335
+ * On expiry the answer is only logged.
336
+ *
337
+ * @param _request - The zone, the mode, and why.
338
+ * @returns `Accepted` once the mode is written, `Rejected` (with
339
+ * `rejectionReason`) when the zone cannot be switched right now,
340
+ * `NotSupported` when the integration cannot switch modes.
341
+ */
342
+ async handleHeatpumpProgramSwitch(_request) {
343
+ return {
344
+ answer: enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.NotSupported,
345
+ rejectionReason: 'Heatpump integration does not support HeatpumpProgramSwitchV3',
346
+ };
347
+ }
283
348
  /**
284
349
  * Called when a `HeatpumpControlV3` session ends, with why:
285
350
  * {@link HeatpumpControlEndCauseEnum.Expired} (no repeat by
@@ -508,6 +573,58 @@ class HeatpumpIntegrationEnergyApp extends integration_energy_app_js_1.Integrati
508
573
  }
509
574
  return response;
510
575
  }
576
+ async dispatchHeatpumpProgramSwitch(message) {
577
+ const { applianceId, data } = message;
578
+ const metadata = await this.resolveHeatpumpMetadata(applianceId);
579
+ const validation = (0, heatpump_control_validators_js_1.validateHeatpumpProgramSwitch)(data, metadata);
580
+ if (validation.answer !== enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Accepted) {
581
+ return validation;
582
+ }
583
+ const isOverride = data.mode !== enyo_heatpump_appliance_js_1.EnyoHeatpumpProgramModeEnum.Program;
584
+ const response = await this.handleHeatpumpProgramSwitch({
585
+ applianceId,
586
+ target: data.target,
587
+ ...(data.index !== undefined ? { index: data.index } : {}),
588
+ mode: data.mode,
589
+ ...(isOverride && data.untilIso !== undefined ? { untilIso: data.untilIso } : {}),
590
+ ...(data.reason !== undefined ? { reason: data.reason } : {}),
591
+ cause: HeatpumpProgramSwitchCauseEnum.Command,
592
+ });
593
+ if (response.answer !== enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Accepted) {
594
+ return response;
595
+ }
596
+ const key = heatpumpProgramSwitchKey(applianceId, data.target, data.index);
597
+ const previous = this.heatpumpProgramSwitchTimers.get(key);
598
+ if (previous !== undefined)
599
+ clearTimeout(previous);
600
+ this.heatpumpProgramSwitchTimers.delete(key);
601
+ if (isOverride) {
602
+ const delayMs = Math.max(0, Date.parse(data.untilIso) - Date.now());
603
+ this.heatpumpProgramSwitchTimers.set(key, setTimeout(() => {
604
+ this.heatpumpProgramSwitchTimers.delete(key);
605
+ void this.expireHeatpumpProgramSwitch(applianceId, data.target, data.index);
606
+ }, delayMs));
607
+ }
608
+ return response;
609
+ }
610
+ async expireHeatpumpProgramSwitch(applianceId, target, index) {
611
+ try {
612
+ const response = await this.handleHeatpumpProgramSwitch({
613
+ applianceId,
614
+ target,
615
+ ...(index !== undefined ? { index } : {}),
616
+ mode: enyo_heatpump_appliance_js_1.EnyoHeatpumpProgramModeEnum.Program,
617
+ cause: HeatpumpProgramSwitchCauseEnum.Expired,
618
+ });
619
+ if (response.answer !== enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.Accepted) {
620
+ console.warn(`HeatpumpIntegrationEnergyApp: switching ${target} ${index ?? 0} of ${applianceId} back to its program ` +
621
+ `after expiry was answered ${response.answer}: ${response.rejectionReason ?? ''}`);
622
+ }
623
+ }
624
+ catch (error) {
625
+ console.error(`HeatpumpIntegrationEnergyApp: switching ${target} ${index ?? 0} of ${applianceId} back to its program failed`, error);
626
+ }
627
+ }
511
628
  async dispatchHeatpumpControlCancel(message) {
512
629
  const response = await this.handleHeatpumpControlCancel(message);
513
630
  if (response.answer !== enyo_data_bus_value_js_1.EnyoCommandAcknowledgeAnswerEnum.NotSupported
@@ -518,3 +635,14 @@ class HeatpumpIntegrationEnergyApp extends integration_energy_app_js_1.Integrati
518
635
  }
519
636
  }
520
637
  exports.HeatpumpIntegrationEnergyApp = HeatpumpIntegrationEnergyApp;
638
+ /**
639
+ * Key of one zone's program-switch timer.
640
+ *
641
+ * @param applianceId - The heatpump appliance.
642
+ * @param target - Room or DHW.
643
+ * @param index - The zone index; omitted counts as `0`.
644
+ * @returns E.g. `heatpump-1|room:0`.
645
+ */
646
+ function heatpumpProgramSwitchKey(applianceId, target, index) {
647
+ return `${applianceId}|${target}:${index ?? 0}`;
648
+ }