@enyo-energy/energy-app-sdk 1.29.0 → 1.31.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 +113 -12
- package/dist/cjs/implementations/appliance-command-forecast/appliance-command-forecast-validators.cjs +69 -1
- package/dist/cjs/implementations/appliance-command-forecast/appliance-command-forecast-validators.d.cts +21 -1
- package/dist/cjs/implementations/heatpump/heatpump-control-session.cjs +147 -0
- package/dist/cjs/implementations/heatpump/heatpump-control-session.d.cts +107 -0
- package/dist/cjs/implementations/heatpump/heatpump-control-validators.cjs +198 -0
- package/dist/cjs/implementations/heatpump/heatpump-control-validators.d.cts +123 -0
- package/dist/cjs/implementations/heatpump/heatpump-flexibility-validators.cjs +163 -0
- package/dist/cjs/implementations/heatpump/heatpump-flexibility-validators.d.cts +61 -0
- package/dist/cjs/implementations/heatpump/heatpump-metadata-validators.cjs +44 -0
- package/dist/cjs/implementations/heatpump/heatpump-metadata-validators.d.cts +10 -1
- package/dist/cjs/index.cjs +3 -0
- package/dist/cjs/index.d.cts +3 -0
- package/dist/cjs/integrations/heatpump-integration-energy-app.cjs +400 -2
- package/dist/cjs/integrations/heatpump-integration-energy-app.d.cts +248 -2
- package/dist/cjs/integrations/integration-energy-app.cjs +4 -2
- package/dist/cjs/integrations/integration-energy-app.d.cts +2 -1
- package/dist/cjs/integrations/integration-types.d.cts +8 -1
- package/dist/cjs/types/enyo-appliance-command-forecast.d.cts +12 -0
- package/dist/cjs/types/enyo-data-bus-value.cjs +130 -6
- package/dist/cjs/types/enyo-data-bus-value.d.cts +705 -6
- package/dist/cjs/types/enyo-heatpump-appliance.cjs +58 -2
- package/dist/cjs/types/enyo-heatpump-appliance.d.cts +81 -2
- package/dist/cjs/version.cjs +1 -1
- package/dist/cjs/version.d.cts +1 -1
- package/dist/implementations/appliance-command-forecast/appliance-command-forecast-validators.d.ts +21 -1
- package/dist/implementations/appliance-command-forecast/appliance-command-forecast-validators.js +69 -2
- package/dist/implementations/heatpump/heatpump-control-session.d.ts +107 -0
- package/dist/implementations/heatpump/heatpump-control-session.js +141 -0
- package/dist/implementations/heatpump/heatpump-control-validators.d.ts +123 -0
- package/dist/implementations/heatpump/heatpump-control-validators.js +192 -0
- package/dist/implementations/heatpump/heatpump-flexibility-validators.d.ts +61 -0
- package/dist/implementations/heatpump/heatpump-flexibility-validators.js +159 -0
- package/dist/implementations/heatpump/heatpump-metadata-validators.d.ts +10 -1
- package/dist/implementations/heatpump/heatpump-metadata-validators.js +45 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/integrations/heatpump-integration-energy-app.d.ts +248 -2
- package/dist/integrations/heatpump-integration-energy-app.js +398 -1
- package/dist/integrations/integration-energy-app.d.ts +2 -1
- package/dist/integrations/integration-energy-app.js +4 -2
- package/dist/integrations/integration-types.d.ts +8 -1
- package/dist/types/enyo-appliance-command-forecast.d.ts +12 -0
- package/dist/types/enyo-data-bus-value.d.ts +705 -6
- package/dist/types/enyo-data-bus-value.js +129 -5
- package/dist/types/enyo-heatpump-appliance.d.ts +81 -2
- package/dist/types/enyo-heatpump-appliance.js +57 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3110,13 +3110,76 @@ class MyHeatpumpApp extends HeatpumpIntegrationEnergyApp {
|
|
|
3110
3110
|
|
|
3111
3111
|
Drives a heatpump. Manages building / DHW overheating commands and grid-power-availability announcements.
|
|
3112
3112
|
|
|
3113
|
-
|
|
3113
|
+
> **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).
|
|
3114
|
+
|
|
3115
|
+
- **Subscribed commands:** `HeatpumpControlV3`, `HeatpumpControlCancelV3`, `HeatpumpOverheatingV1` (deprecated), `HeatpumpAvailablePowerAnnouncementV1` (deprecated), `SetHeatpumpAvailablePowerV2` (deprecated), `SetHeatpumpRoomTemperatureV1`, `GridOperatorPowerLimitationV1` (broadcast)
|
|
3114
3116
|
- **Implement:** `handleHeatpumpOverheating`, `handleHeatpumpAvailablePowerAnnouncement`, `handleSetHeatpumpAvailablePower`, `handleGridOperatorPowerLimitation`
|
|
3115
|
-
- **Optional
|
|
3117
|
+
- **Optional overrides:**
|
|
3118
|
+
- `handleSetHeatpumpRoomTemperature` — write a measured room temperature into a heating circuit. Answers `NotSupported` by default; override it when the heatpump declares `RoomTemperatureInput`.
|
|
3119
|
+
- `handleHeatpumpControl` — support `HeatpumpControlV3` (see below). Answers `NotSupported` by default; override it and declare `ControlV3`.
|
|
3120
|
+
- `handleHeatpumpControlCancel` — withdraw a cancelled session on the device. By default `Accepted` for heatpumps declaring `ControlV3`, `NotSupported` otherwise.
|
|
3121
|
+
- `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.
|
|
3122
|
+
- `resolveHeatpumpMetadata(applianceId)` — the metadata actions are validated against. Defaults to the stored appliance's `heatpump` metadata.
|
|
3123
|
+
- **Helpers for a V3 implementation:** `checkHeatpumpControlActions(message)` (validates every action against metadata and offer), `isHeatpumpControlChanged(message)` (new session or changed actions?), and the exported `computeHeatpumpSetpointRaise(...)` and `combineHeatpumpControlAnswers(...)`.
|
|
3116
3124
|
- **Publish helpers:**
|
|
3125
|
+
- `publishHeatpumpControlStatus(applianceId, {sessionId, actions})` — how a session's actions progress (`HeatpumpControlStatusV3`).
|
|
3126
|
+
- `publishHeatpumpFlexibility(applianceId, options, {validForSeconds?, repeatEverySeconds?})` — offer per zone how far the heatpump can be raised right now (`HeatpumpFlexibilityAnnouncementV3`); repeated automatically until replaced (see below).
|
|
3117
3127
|
- `publishHeatpumpValuesUpdate(applianceId, values)` — operation mode, electrical and thermal power, energies.
|
|
3118
3128
|
- `publishHeatpumpTemperatures(applianceId, temperatures)` — outdoor, flow / target flow / return, DHW tanks, heating circuits (room, flow, target flow, return), buffer tank.
|
|
3119
3129
|
|
|
3130
|
+
#### HeatpumpControlV3
|
|
3131
|
+
|
|
3132
|
+
**The preferred way to control a heatpump.** Energy managers send `HeatpumpControlV3` / `HeatpumpControlCancelV3` to heatpumps declaring `ControlV3`, and the deprecated `HeatpumpOverheatingV1` / `SetHeatpumpAvailablePowerV2` to all others — the choice comes from the metadata, not from probing. Integrations override `handleHeatpumpControl`, declare `ControlV3` (and `PvSurplusSignal` where the device takes a PV surplus signal), and report progress with `publishHeatpumpControlStatus`.
|
|
3133
|
+
|
|
3134
|
+
One message per control session says **how much** power (`availablePowerW`), **from where** (`powerSources`: PV / battery / grid), **which zones to heat now** (`actions`: DHW, buffer tank or room, each with `index`, `deltaK` and an optional `heatSource`) and **until when** (`validUntilIso`).
|
|
3135
|
+
|
|
3136
|
+
```typescript
|
|
3137
|
+
{
|
|
3138
|
+
sessionId: 'night-tariff-2026-10-15', // the same across every repeat
|
|
3139
|
+
validUntilIso: '2026-10-15T00:01:00.000Z', // liveness: repeated ~every 10 s, ~60 s ahead
|
|
3140
|
+
availablePowerW: 3000,
|
|
3141
|
+
powerSources: [{ source: EnyoPowerSourceEnum.Grid, powerW: 3000 }],
|
|
3142
|
+
actions: [{ target: EnyoHeatpumpControlTargetEnum.Dhw, index: 0, deltaK: 5 }],
|
|
3143
|
+
}
|
|
3144
|
+
```
|
|
3145
|
+
|
|
3146
|
+
- **An action means "heat this zone now".** It pulls heating into a cheap or sunny period — e.g. night tariff 0–5 h. Answer it `Accepted` only when the device will actually start heating. A setpoint-only heatpump (Stiebel Eltron, Nibe) only starts once the temperature is a hysteresis below the setpoint, so write at least `currentTemperature + hysteresisK` (`domesticHotWater[].hysteresisK` / `bufferTanks[].hysteresisK`) even if `deltaK` asks for less. If `maxTemperatureC` leaves no such setpoint, answer `Rejected` — never `Accepted` with nothing happening. `computeHeatpumpSetpointRaise({setpointC, deltaK, currentTemperatureC, hysteresisK, maxTemperatureC})` does the arithmetic the same way for every app.
|
|
3147
|
+
- **Room `deltaK` is always Kelvin of room temperature.** On an outdoor-controlled circuit (`heatingControlReference: Outdoor`) the app translates it into a heating-curve shift, on a room-controlled one into a higher room setpoint. The energy manager never sends a curve shift. Above the circuit's `heatingCutoffOutdoorTemperatureC` a room action cannot take effect and is `Rejected`.
|
|
3148
|
+
- **Report what was written.** Each `actionResults` entry carries `appliedTargetTemperatureC` (the setpoint written), `appliedDeltaK` (the effective raise, possibly above `deltaK`) and, for a room on an outdoor-controlled circuit, `appliedHeatingCurveShiftK`.
|
|
3149
|
+
- **Sessions.** Repeats arrive about every 10 s with the same `sessionId`; apply the actions only when `isHeatpumpControlChanged(message)` says the session or its actions changed, and just update the power otherwise. The base class handles liveness for every V3 implementation: it rejects a message whose `validUntilIso` is invalid or past, and calls `onHeatpumpControlEnded` when no repeat arrives by `validUntilIso` (`Expired`), on a message with no actions and no `availablePowerW` (`Ended` — passed to `handleHeatpumpControl` first, so the device can be reset at once), or on a cancel (`Cancelled`).
|
|
3150
|
+
- **Cancelling.** `HeatpumpControlCancelV3` with a `sessionId` cancels that session right away, instead of waiting up to ~60 s for `validUntilIso` to lapse. It only touches the named session, so a newer one that replaced it is safe, which an empty `HeatpumpControlV3` cannot guarantee. Idempotent: `Accepted` also when the session is no longer running. Stop repeating the session first, or the next repeat starts it again.
|
|
3151
|
+
- **Answers per action.** The acknowledgment carries one `actionResults` entry per action; return them in `IntegrationCommandResponse.actionResults` and the base class passes them through. The top-level `answer` is `Accepted` as soon as the power or one action is accepted, `NotSupported` only when nothing is supported, otherwise `Rejected` (`combineHeatpumpControlAnswers`).
|
|
3152
|
+
- **Progress.** `HeatpumpControlStatusV3` reports each action as `Waiting` (accepted, not heating yet — defrost, minimum off-time), `Running`, `Completed` (target reached — the energy manager may end the session early) or `Aborted` (stopped or refused — the energy manager re-plans), with `currentTemperatureC`, `appliedTargetTemperatureC`, `startedAtIso`, `endedAtIso` and a `reason`. Send it on every state change and once more when the session ends.
|
|
3153
|
+
- **Validation** (`checkHeatpumpControlActions(message)`, or `validateHeatpumpControlAction(action, metadata, powerSources, offer?)` directly):
|
|
3154
|
+
- A heating rod (`heatSource` `HeatingRod` / `Both`) runs on PV surplus only: rejected unless `powerSources` are PV alone, and `NotSupported` without `HeatingRodControllable`.
|
|
3155
|
+
- Each target needs its overheating feature (`DomesticHotWaterOverheating`, `BufferTankOverheating`, `RoomOverheating`), else `NotSupported`.
|
|
3156
|
+
- DHW must stay within `domesticHotWater[].maxTemperatureC`: setpoint + `deltaK` above it is rejected.
|
|
3157
|
+
- When the session names an `offerId`, each action must fit the heatpump's current offer.
|
|
3158
|
+
|
|
3159
|
+
#### HeatpumpFlexibilityAnnouncementV3
|
|
3160
|
+
|
|
3161
|
+
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`.
|
|
3162
|
+
|
|
3163
|
+
```typescript
|
|
3164
|
+
const offerId = await this.publishHeatpumpFlexibility('heatpump-1', [{
|
|
3165
|
+
target: EnyoHeatpumpControlTargetEnum.Dhw,
|
|
3166
|
+
index: 0,
|
|
3167
|
+
currentTemperatureC: 47,
|
|
3168
|
+
setpointC: 50,
|
|
3169
|
+
maxDeltaK: 10,
|
|
3170
|
+
heatSources: [
|
|
3171
|
+
{ heatSource: EnyoHeatpumpHeatSourceEnum.Compressor, power: { minWatt: 800, maxWatt: 2300, stepWatt: 100 }, estimatedElectricalKwh: 1.4 },
|
|
3172
|
+
{ heatSource: EnyoHeatpumpHeatSourceEnum.HeatingRod, power: { minWatt: 3000, maxWatt: 3000, stepWatt: 3000 }, estimatedElectricalKwh: 4.1 },
|
|
3173
|
+
],
|
|
3174
|
+
required: { deltaK: 3, byIso: '2026-10-14T18:00:00.000Z' },
|
|
3175
|
+
}]);
|
|
3176
|
+
```
|
|
3177
|
+
|
|
3178
|
+
- **Lifecycle.** The latest offer per appliance replaces the previous one and holds until `validUntilIso`. `publishHeatpumpFlexibility` repeats it with a fresh `validUntilIso` (default: valid 15 min, repeated every 5 min) until you call it again, and stops repeating on `stop()`. Publish `[]` to withdraw the offer.
|
|
3179
|
+
- **`offerId`** is derived from what is offered (`heatpumpFlexibilityOfferId`), ignoring measured values and order — it stays the same while the tank warms up and changes when the offer changes.
|
|
3180
|
+
- **Answering an offer.** The energy manager answers with `HeatpumpControlV3` — one action per offered zone, `deltaK` ≤ `maxDeltaK`, a `heatSource` from the offered ones — and names the offer in `data.offerId`. When a session names an `offerId`, the base class checks every action against the heatpump's current offer and rejects what was not offered. A session without `offerId` is the energy manager's own plan and is checked against the metadata only.
|
|
3181
|
+
- **Validation** (`validateHeatpumpFlexibilityAnnouncement(data, metadata?)`, run by `publishHeatpumpFlexibility` before sending): `maxDeltaK` positive; at least one heat source, none twice, a sane power band; start window and `required.byIso` valid, `required.deltaK` ≤ `maxDeltaK`; each zone at most once; and against the metadata, DHW `setpointC + maxDeltaK` ≤ `maxTemperatureC`, a heating rod only with `HeatingRodControllable`, and each target's overheating feature declared.
|
|
3182
|
+
|
|
3120
3183
|
#### Advanced heatpump metadata
|
|
3121
3184
|
|
|
3122
3185
|
Beyond tanks and circuits, the heatpump metadata (`EnyoHeatpumpApplianceMetadata`) can carry what the device is configured to do. Declare each part with its feature flag in `availableFeatures` so consumers know to expect it:
|
|
@@ -3127,6 +3190,11 @@ Beyond tanks and circuits, the heatpump metadata (`EnyoHeatpumpApplianceMetadata
|
|
|
3127
3190
|
| `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` |
|
|
3128
3191
|
| `RoomTemperature` | `heatingCircuits[].roomTemperatureC` in `HeatpumpTemperaturesUpdateV1`; where it is measured goes in `heatingCircuits[].roomTemperatureSource` (`None` / `Internal` / `External`) and, for an enyo sensor, `roomTemperatureSensor` |
|
|
3129
3192
|
| `RoomTemperatureInput` | The heatpump accepts measured room temperatures via `SetHeatpumpRoomTemperatureV1` |
|
|
3193
|
+
| `PvSurplusSignal` | The heatpump takes `availablePowerW` of `HeatpumpControlV3` as a PV surplus signal (e.g. Lambda register 102). Supersedes the deprecated `AvailablePowerAnnouncement` |
|
|
3194
|
+
| `ControlV3` | The heatpump's app handles `HeatpumpControlV3` / `HeatpumpControlCancelV3` and reports `HeatpumpControlStatusV3`. The energy manager sends V3 only to heatpumps declaring it. Needs at least one overheating feature to be actionable |
|
|
3195
|
+
| `FlexibilityAnnouncementV3` | The heatpump publishes `HeatpumpFlexibilityAnnouncementV3` offers; the energy manager plans from them instead of deriving the headroom from metadata. Needs at least one overheating feature to be actionable |
|
|
3196
|
+
| — | `heatingCircuits[].heatingControlReference` — what the circuit steers by: `Outdoor` (heating curve) or `Room`. Independent of `roomTemperatureSource`, which says where the room is measured |
|
|
3197
|
+
| — | `heatingCircuits[].heatingCutoffOutdoorTemperatureC` — the heatpump's own heating cut-off (Heizgrenze / Sommerabschaltung) setting. Above it a room action cannot take effect. Unlike the learned `building.heatingLimitC`; the validator warns when the two differ by more than 3 K |
|
|
3130
3198
|
| `FlowTemperature` / `ReturnTemperature` | `heatpumpFlowTemperatureC`, `heatpumpTargetFlowTemperatureC`, `heatpumpReturnTemperatureC` and per-circuit `flowTemperatureC`, `targetFlowTemperatureC`, `returnTemperatureC` in `HeatpumpTemperaturesUpdateV1` |
|
|
3131
3199
|
|
|
3132
3200
|
`updateAppliance` replaces the whole `heatingCircuits` / `domesticHotWater` array, so use the `ApplianceManager` helpers to change a single entry:
|
|
@@ -3522,33 +3590,58 @@ Both are for marking on the same power scale a consumer already draws from `maxC
|
|
|
3522
3590
|
|
|
3523
3591
|
### BatteryCommandForecast
|
|
3524
3592
|
|
|
3525
|
-
|
|
3593
|
+
A discriminated union on `mode`:
|
|
3594
|
+
|
|
3595
|
+
- `mode: 'auto'` — hand the battery back to its own logic over the horizon; no schedule. May carry an optional `reason` (`EnyoDataBusCommandReason`) saying **why** nothing is prescribed — e.g. `PriceSpreadTooSmall` when trading would not pay. Set it only when every Auto slot of the horizon shares the same reason; when they differ, leave it out rather than guess. Without it, an empty Auto forecast tends to read as "no forecast".
|
|
3596
|
+
- `mode: 'schedule'` — follow `relativeSchedule`, a list of `{seconds, direction, powerW}` setpoints, where `direction` is `charge`, `discharge` or `idle`.
|
|
3526
3597
|
|
|
3527
3598
|
The type is named `BatteryCommandForecast` to make the distinction with the existing [`BatteryForecast`](#batteryforecast) class (which forecasts state-of-charge from history) explicit.
|
|
3528
3599
|
|
|
3529
3600
|
```typescript
|
|
3530
3601
|
import {
|
|
3602
|
+
ApplianceForecastResolutionEnum,
|
|
3531
3603
|
BatteryCommandForecast,
|
|
3604
|
+
BatteryCommandForecastDirectionEnum,
|
|
3532
3605
|
BatteryCommandForecastModeEnum,
|
|
3606
|
+
EnyoCurrencyEnum,
|
|
3607
|
+
EnyoDataBusCommandReasonCategoryEnum,
|
|
3608
|
+
EnyoDataBusCommandReasonTypeEnum,
|
|
3533
3609
|
} from '@enyo-energy/energy-app-sdk';
|
|
3534
3610
|
|
|
3535
|
-
|
|
3611
|
+
// A planned cadence:
|
|
3612
|
+
const scheduled: BatteryCommandForecast = {
|
|
3613
|
+
resolution: ApplianceForecastResolutionEnum.FifteenMinutes,
|
|
3614
|
+
mode: BatteryCommandForecastModeEnum.Schedule,
|
|
3536
3615
|
relativeSchedule: [
|
|
3537
|
-
{ seconds: 0,
|
|
3538
|
-
{ seconds: 1800,
|
|
3539
|
-
{ seconds: 3600,
|
|
3616
|
+
{ seconds: 0, direction: BatteryCommandForecastDirectionEnum.Charge, powerW: 3000 },
|
|
3617
|
+
{ seconds: 1800, direction: BatteryCommandForecastDirectionEnum.Discharge, powerW: 2500 },
|
|
3618
|
+
{ seconds: 3600, direction: BatteryCommandForecastDirectionEnum.Idle, powerW: 0 },
|
|
3540
3619
|
],
|
|
3541
3620
|
estimatedSavings: { costSavings: 0.18, currency: 'EUR' },
|
|
3542
3621
|
};
|
|
3622
|
+
await forecasts.publishBatteryForecast('battery-1', scheduled);
|
|
3543
3623
|
|
|
3544
|
-
|
|
3624
|
+
// Nothing planned, and why:
|
|
3625
|
+
const auto: BatteryCommandForecast = {
|
|
3626
|
+
resolution: ApplianceForecastResolutionEnum.FifteenMinutes,
|
|
3627
|
+
mode: BatteryCommandForecastModeEnum.Auto,
|
|
3628
|
+
reason: {
|
|
3629
|
+
type: EnyoDataBusCommandReasonTypeEnum.PriceSpreadTooSmall,
|
|
3630
|
+
category: EnyoDataBusCommandReasonCategoryEnum.EnergyPrice,
|
|
3631
|
+
electricityPricePerKwh: 0.352,
|
|
3632
|
+
currency: EnyoCurrencyEnum.EUR,
|
|
3633
|
+
context: { arbitrage: { buyPricePerKwh: 0.352, breakEvenPricePerKwh: 0.527, sellPricePerKwh: 0.429, roundTripEfficiency: 0.757 } },
|
|
3634
|
+
},
|
|
3635
|
+
};
|
|
3636
|
+
await forecasts.publishBatteryForecast('battery-1', auto);
|
|
3545
3637
|
```
|
|
3546
3638
|
|
|
3547
|
-
|
|
3639
|
+
Invariants:
|
|
3548
3640
|
|
|
3549
|
-
- `
|
|
3550
|
-
- `
|
|
3551
|
-
- `
|
|
3641
|
+
- `mode: 'auto'` must not carry a `relativeSchedule`. A `reason` must have a valid `type`; its `context.arbitrage` prices must be EUR/kWh within a plausible band (a ct value such as `35.2` is rejected), `roundTripEfficiency` must lie in (0, 1], and each window's start must lie before its end.
|
|
3642
|
+
- `seconds`: finite, non-negative; first entry `= 0`; strictly increasing; a multiple of the `resolution` step.
|
|
3643
|
+
- `direction`: one of `charge` / `discharge` / `idle`. `idle` hands the slot to the battery's own logic — to block charging or discharging, use that direction with `powerW = 0` instead.
|
|
3644
|
+
- `powerW`: finite, non-negative. **MUST be `0` when `direction === 'idle'`.**
|
|
3552
3645
|
|
|
3553
3646
|
### HeatpumpForecast
|
|
3554
3647
|
|
|
@@ -4190,6 +4283,14 @@ New reason types came with this surface, so a skipped row can say *why* instead
|
|
|
4190
4283
|
|
|
4191
4284
|
**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
4285
|
|
|
4286
|
+
**Battery trading: why or why not.** The `arbitrage` context (`EnyoDataBusCommandReasonArbitrageContext`) carries the trough-against-peak arithmetic behind a battery buy: `buyPricePerKwh` / `buyStartIso` / `buyEndIso`, `sellPricePerKwh` / `sellStartIso` / `sellEndIso`, `breakEvenPricePerKwh` (losses, wear and margin included) and `roundTripEfficiency` (a fraction, `0.757`). Prices are **EUR/kWh like every machine-readable price in the SDK** — `0.336`, not `33.6`. Three uses:
|
|
4287
|
+
|
|
4288
|
+
- `PriceSpreadTooSmall` (category `EnergyPrice`) — the battery stays on Auto because buying in the trough and using the energy at the peak would not pay back: "Charging at 35.2 ct/kWh only pays off from 52.7 ct/kWh, but the peak averages 42.9 ct/kWh." Set `electricityPricePerKwh` to the buy price and all the figures; on a flat horizon send it without `arbitrage`. The trough is not "too expensive" — the gap is too small — so this is not `ElectricityPriceAboveThreshold`. Where PV makes Auto mean self-consumption, `SelfConsumptionOptimization` stays the right type.
|
|
4289
|
+
- `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."
|
|
4290
|
+
- `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.
|
|
4291
|
+
|
|
4292
|
+
**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.
|
|
4293
|
+
|
|
4193
4294
|
**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
4295
|
|
|
4195
4296
|
**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."
|
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.ApplianceCommandForecastValidationError = void 0;
|
|
4
4
|
exports.validateChargerForecast = validateChargerForecast;
|
|
5
5
|
exports.validateBatteryCommandForecast = validateBatteryCommandForecast;
|
|
6
|
+
exports.validateBatteryForecastReason = validateBatteryForecastReason;
|
|
6
7
|
exports.validateHeatpumpForecast = validateHeatpumpForecast;
|
|
7
8
|
exports.validateChargerSchedule = validateChargerSchedule;
|
|
8
9
|
exports.validateBatterySchedule = validateBatterySchedule;
|
|
@@ -69,7 +70,9 @@ function validateChargerForecast(forecast) {
|
|
|
69
70
|
* {@link BatteryCommandForecastModeEnum}: when `mode = 'auto'` no
|
|
70
71
|
* schedule is expected; when `mode = 'schedule'` the embedded
|
|
71
72
|
* {@link BatteryCommandForecastScheduled.relativeSchedule} is validated
|
|
72
|
-
* by {@link validateBatterySchedule}.
|
|
73
|
+
* by {@link validateBatterySchedule}. An Auto forecast's optional
|
|
74
|
+
* {@link BatteryCommandForecastAuto.reason} is checked by
|
|
75
|
+
* {@link validateBatteryForecastReason}.
|
|
73
76
|
*/
|
|
74
77
|
function validateBatteryCommandForecast(forecast) {
|
|
75
78
|
if (!forecast || typeof forecast !== 'object') {
|
|
@@ -84,10 +87,75 @@ function validateBatteryCommandForecast(forecast) {
|
|
|
84
87
|
if (forecast.relativeSchedule !== undefined) {
|
|
85
88
|
throw new ApplianceCommandForecastValidationError("BatteryCommandForecast with mode='auto' must not carry a relativeSchedule.");
|
|
86
89
|
}
|
|
90
|
+
if (forecast.reason !== undefined) {
|
|
91
|
+
validateBatteryForecastReason(forecast.reason, 'BatteryCommandForecast.reason');
|
|
92
|
+
}
|
|
87
93
|
return;
|
|
88
94
|
}
|
|
89
95
|
validateBatterySchedule(forecast.relativeSchedule, forecast.resolution);
|
|
90
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* Lowest and highest plausible price per kWh in a reason's `arbitrage`
|
|
99
|
+
* context, in EUR/kWh. Wide enough for negative and spiking dynamic prices,
|
|
100
|
+
* narrow enough to catch a ct value (`33.6`) in a EUR field.
|
|
101
|
+
*/
|
|
102
|
+
const ARBITRAGE_PRICE_MIN_PER_KWH = -1;
|
|
103
|
+
const ARBITRAGE_PRICE_MAX_PER_KWH = 5;
|
|
104
|
+
/**
|
|
105
|
+
* Validates the reason attached to an Auto {@link BatteryCommandForecast}.
|
|
106
|
+
* Throws on the first violation.
|
|
107
|
+
*
|
|
108
|
+
* Checks that the reason is an object whose `type` is an
|
|
109
|
+
* {@link EnyoDataBusCommandReasonTypeEnum} member and, when it carries
|
|
110
|
+
* `context.arbitrage`, that:
|
|
111
|
+
* - every price is a finite EUR/kWh figure within a plausible band (catches
|
|
112
|
+
* ct in a EUR field),
|
|
113
|
+
* - `roundTripEfficiency` lies in (0, 1] (catches `75.7` for `0.757`),
|
|
114
|
+
* - each window's start lies before its end, where both are given.
|
|
115
|
+
*
|
|
116
|
+
* @param reason - The reason to validate.
|
|
117
|
+
* @param fieldName - Field path used in error messages.
|
|
118
|
+
* @throws {ApplianceCommandForecastValidationError} On the first violation.
|
|
119
|
+
*/
|
|
120
|
+
function validateBatteryForecastReason(reason, fieldName) {
|
|
121
|
+
if (!reason || typeof reason !== 'object') {
|
|
122
|
+
throw new ApplianceCommandForecastValidationError(`${fieldName} must be an object.`);
|
|
123
|
+
}
|
|
124
|
+
validateEnumField(reason.type, Object.values(enyo_data_bus_value_js_1.EnyoDataBusCommandReasonTypeEnum), `${fieldName}.type`, true);
|
|
125
|
+
const arbitrage = reason.context?.arbitrage;
|
|
126
|
+
if (arbitrage === undefined)
|
|
127
|
+
return;
|
|
128
|
+
for (const key of ['buyPricePerKwh', 'sellPricePerKwh', 'breakEvenPricePerKwh']) {
|
|
129
|
+
const price = arbitrage[key];
|
|
130
|
+
if (price === undefined)
|
|
131
|
+
continue;
|
|
132
|
+
if (!Number.isFinite(price)) {
|
|
133
|
+
throw new ApplianceCommandForecastValidationError(`${fieldName}.context.arbitrage.${key} must be a finite number; got ${price}.`);
|
|
134
|
+
}
|
|
135
|
+
if (price < ARBITRAGE_PRICE_MIN_PER_KWH || price > ARBITRAGE_PRICE_MAX_PER_KWH) {
|
|
136
|
+
throw new ApplianceCommandForecastValidationError(`${fieldName}.context.arbitrage.${key} is ${price}, outside ` +
|
|
137
|
+
`${ARBITRAGE_PRICE_MIN_PER_KWH}…${ARBITRAGE_PRICE_MAX_PER_KWH} — prices are per kWh in ` +
|
|
138
|
+
'the reason\'s currency (EUR/kWh), not ct/kWh.');
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const efficiency = arbitrage.roundTripEfficiency;
|
|
142
|
+
if (efficiency !== undefined && (!Number.isFinite(efficiency) || efficiency <= 0 || efficiency > 1)) {
|
|
143
|
+
throw new ApplianceCommandForecastValidationError(`${fieldName}.context.arbitrage.roundTripEfficiency is ${efficiency}; it is a fraction in (0, 1], ` +
|
|
144
|
+
'e.g. 0.757 rather than 75.7.');
|
|
145
|
+
}
|
|
146
|
+
for (const [start, end] of [['buyStartIso', 'buyEndIso'], ['sellStartIso', 'sellEndIso']]) {
|
|
147
|
+
const startIso = arbitrage[start];
|
|
148
|
+
const endIso = arbitrage[end];
|
|
149
|
+
for (const [key, iso] of [[start, startIso], [end, endIso]]) {
|
|
150
|
+
if (iso !== undefined && Number.isNaN(Date.parse(iso))) {
|
|
151
|
+
throw new ApplianceCommandForecastValidationError(`${fieldName}.context.arbitrage.${key} is not an ISO 8601 timestamp: ${iso}.`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (startIso !== undefined && endIso !== undefined && Date.parse(startIso) >= Date.parse(endIso)) {
|
|
155
|
+
throw new ApplianceCommandForecastValidationError(`${fieldName}.context.arbitrage.${start} must lie before ${end}.`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
91
159
|
/**
|
|
92
160
|
* Validates a {@link HeatpumpForecast}. Throws on the first violation —
|
|
93
161
|
* the error message names the offending field / index.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AirConditioningForecast, AirConditioningForecastScheduleEntry, ApplianceForecastResolutionEnum, BatteryCommandForecast, BatteryCommandForecastScheduleEntry, ChargerForecast, ChargerForecastScheduleEntry, HeatingRodForecast, HeatingRodForecastScheduleEntry, HeatpumpForecast, HeatpumpForecastScheduleEntry, SmartPlugForecast, SmartPlugForecastScheduleEntry } from '../../types/enyo-appliance-command-forecast.cjs';
|
|
2
|
+
import { type EnyoDataBusCommandReason } from '../../types/enyo-data-bus-value.cjs';
|
|
2
3
|
/**
|
|
3
4
|
* Thrown when a forecast payload passed to one of the validators (or to
|
|
4
5
|
* {@link EnergyAppApplianceEnergyManagerForecast.publishChargerForecast}
|
|
@@ -23,9 +24,28 @@ export declare function validateChargerForecast(forecast: ChargerForecast): void
|
|
|
23
24
|
* {@link BatteryCommandForecastModeEnum}: when `mode = 'auto'` no
|
|
24
25
|
* schedule is expected; when `mode = 'schedule'` the embedded
|
|
25
26
|
* {@link BatteryCommandForecastScheduled.relativeSchedule} is validated
|
|
26
|
-
* by {@link validateBatterySchedule}.
|
|
27
|
+
* by {@link validateBatterySchedule}. An Auto forecast's optional
|
|
28
|
+
* {@link BatteryCommandForecastAuto.reason} is checked by
|
|
29
|
+
* {@link validateBatteryForecastReason}.
|
|
27
30
|
*/
|
|
28
31
|
export declare function validateBatteryCommandForecast(forecast: BatteryCommandForecast): void;
|
|
32
|
+
/**
|
|
33
|
+
* Validates the reason attached to an Auto {@link BatteryCommandForecast}.
|
|
34
|
+
* Throws on the first violation.
|
|
35
|
+
*
|
|
36
|
+
* Checks that the reason is an object whose `type` is an
|
|
37
|
+
* {@link EnyoDataBusCommandReasonTypeEnum} member and, when it carries
|
|
38
|
+
* `context.arbitrage`, that:
|
|
39
|
+
* - every price is a finite EUR/kWh figure within a plausible band (catches
|
|
40
|
+
* ct in a EUR field),
|
|
41
|
+
* - `roundTripEfficiency` lies in (0, 1] (catches `75.7` for `0.757`),
|
|
42
|
+
* - each window's start lies before its end, where both are given.
|
|
43
|
+
*
|
|
44
|
+
* @param reason - The reason to validate.
|
|
45
|
+
* @param fieldName - Field path used in error messages.
|
|
46
|
+
* @throws {ApplianceCommandForecastValidationError} On the first violation.
|
|
47
|
+
*/
|
|
48
|
+
export declare function validateBatteryForecastReason(reason: EnyoDataBusCommandReason, fieldName: string): void;
|
|
29
49
|
/**
|
|
30
50
|
* Validates a {@link HeatpumpForecast}. Throws on the first violation —
|
|
31
51
|
* the error message names the offending field / index.
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.HeatpumpControlSessionTracker = exports.HeatpumpControlEndCauseEnum = void 0;
|
|
4
|
+
exports.heatpumpControlActionKey = heatpumpControlActionKey;
|
|
5
|
+
exports.heatpumpControlActionsKey = heatpumpControlActionsKey;
|
|
6
|
+
/**
|
|
7
|
+
* Why a `HeatpumpControlV3` session ended — passed to
|
|
8
|
+
* `HeatpumpIntegrationEnergyApp.onHeatpumpControlEnded`.
|
|
9
|
+
*/
|
|
10
|
+
var HeatpumpControlEndCauseEnum;
|
|
11
|
+
(function (HeatpumpControlEndCauseEnum) {
|
|
12
|
+
/** `validUntilIso` passed without a newer message (the energy manager stopped repeating it). */
|
|
13
|
+
HeatpumpControlEndCauseEnum["Expired"] = "expired";
|
|
14
|
+
/** The energy manager sent a `HeatpumpControlV3` with no actions and no power. */
|
|
15
|
+
HeatpumpControlEndCauseEnum["Ended"] = "ended";
|
|
16
|
+
/** The energy manager cancelled the session with `HeatpumpControlCancelV3`. */
|
|
17
|
+
HeatpumpControlEndCauseEnum["Cancelled"] = "cancelled";
|
|
18
|
+
})(HeatpumpControlEndCauseEnum || (exports.HeatpumpControlEndCauseEnum = HeatpumpControlEndCauseEnum = {}));
|
|
19
|
+
/**
|
|
20
|
+
* Key that identifies an action within a session — its target and zone index.
|
|
21
|
+
*
|
|
22
|
+
* @param action - The action.
|
|
23
|
+
* @returns E.g. `dhw:0`, or `room:` for an action without index.
|
|
24
|
+
*/
|
|
25
|
+
function heatpumpControlActionKey(action) {
|
|
26
|
+
return `${action.target}:${action.index ?? ''}`;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Stable key of a whole action list, independent of the order the actions
|
|
30
|
+
* were sent in. Two lists with the same key ask for the same thing.
|
|
31
|
+
*
|
|
32
|
+
* @param actions - The session's actions.
|
|
33
|
+
* @returns A string that changes whenever any action's target, index,
|
|
34
|
+
* `deltaK` or `heatSource` changes.
|
|
35
|
+
*/
|
|
36
|
+
function heatpumpControlActionsKey(actions) {
|
|
37
|
+
return actions
|
|
38
|
+
.map((a) => [a.target, a.index ?? '', a.deltaK ?? '', a.heatSource ?? ''].join('|'))
|
|
39
|
+
.sort()
|
|
40
|
+
.join(';');
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Tracks the `HeatpumpControlV3` session of each heatpump: which session is
|
|
44
|
+
* running, which of its actions have been applied, and when it lapses.
|
|
45
|
+
*
|
|
46
|
+
* Rules:
|
|
47
|
+
* - Same `sessionId` and same actions → the session continues; only
|
|
48
|
+
* {@link HeatpumpControlSessionState.validUntilMs} moves.
|
|
49
|
+
* - New `sessionId` or changed actions → a fresh state; every action is
|
|
50
|
+
* applied anew.
|
|
51
|
+
* - No new message by `validUntilIso` → the state is dropped and `onExpired`
|
|
52
|
+
* is called for that appliance.
|
|
53
|
+
*
|
|
54
|
+
* Used by `HeatpumpIntegrationEnergyApp`; exported so integrations that
|
|
55
|
+
* override `handleHeatpumpControl` can reuse the same rules.
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* ```typescript
|
|
59
|
+
* const tracker = new HeatpumpControlSessionTracker((applianceId) => resetHeatpump(applianceId));
|
|
60
|
+
* const {state, isNew} = tracker.track(message.applianceId, message.data);
|
|
61
|
+
* if (isNew) applyActions(message.data.actions);
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
64
|
+
class HeatpumpControlSessionTracker {
|
|
65
|
+
onExpired;
|
|
66
|
+
now;
|
|
67
|
+
sessions = new Map();
|
|
68
|
+
timers = new Map();
|
|
69
|
+
/**
|
|
70
|
+
* @param onExpired - Called when an appliance's session lapses because no
|
|
71
|
+
* message arrived by its `validUntilIso`. Not called by {@link end}.
|
|
72
|
+
* @param now - Clock in epoch milliseconds; injectable for tests.
|
|
73
|
+
*/
|
|
74
|
+
constructor(onExpired, now = () => Date.now()) {
|
|
75
|
+
this.onExpired = onExpired;
|
|
76
|
+
this.now = now;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The current session state of an appliance, if one is running.
|
|
80
|
+
*
|
|
81
|
+
* @param applianceId - The heatpump appliance.
|
|
82
|
+
* @returns The state, or `undefined`.
|
|
83
|
+
*/
|
|
84
|
+
get(applianceId) {
|
|
85
|
+
return this.sessions.get(applianceId);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Records a received session message and (re)arms its expiry.
|
|
89
|
+
*
|
|
90
|
+
* @param applianceId - The heatpump appliance the message is for.
|
|
91
|
+
* @param data - The message's data.
|
|
92
|
+
* @returns The appliance's state, and `isNew` when the session or its
|
|
93
|
+
* actions changed — the moment to apply the actions anew.
|
|
94
|
+
* @throws {Error} When `validUntilIso` is not an ISO 8601 timestamp.
|
|
95
|
+
*/
|
|
96
|
+
track(applianceId, data) {
|
|
97
|
+
const validUntilMs = Date.parse(data.validUntilIso);
|
|
98
|
+
if (!Number.isFinite(validUntilMs)) {
|
|
99
|
+
throw new Error(`validUntilIso is not an ISO 8601 timestamp: ${data.validUntilIso}`);
|
|
100
|
+
}
|
|
101
|
+
const actionsKey = heatpumpControlActionsKey(data.actions ?? []);
|
|
102
|
+
const current = this.sessions.get(applianceId);
|
|
103
|
+
const isNew = !current || current.sessionId !== data.sessionId || current.actionsKey !== actionsKey;
|
|
104
|
+
const state = isNew
|
|
105
|
+
? { sessionId: data.sessionId, actionsKey, validUntilMs }
|
|
106
|
+
: { ...current, validUntilMs };
|
|
107
|
+
this.sessions.set(applianceId, state);
|
|
108
|
+
this.armExpiry(applianceId, validUntilMs);
|
|
109
|
+
return { state, isNew };
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Ends an appliance's session without calling `onExpired` — for an
|
|
113
|
+
* explicit end the caller handles itself.
|
|
114
|
+
*
|
|
115
|
+
* @param applianceId - The heatpump appliance.
|
|
116
|
+
* @returns `true` when a session was running.
|
|
117
|
+
*/
|
|
118
|
+
end(applianceId) {
|
|
119
|
+
this.clearTimer(applianceId);
|
|
120
|
+
return this.sessions.delete(applianceId);
|
|
121
|
+
}
|
|
122
|
+
/** Drops every session and timer. Call on shutdown. */
|
|
123
|
+
dispose() {
|
|
124
|
+
for (const applianceId of [...this.timers.keys()])
|
|
125
|
+
this.clearTimer(applianceId);
|
|
126
|
+
this.sessions.clear();
|
|
127
|
+
}
|
|
128
|
+
armExpiry(applianceId, validUntilMs) {
|
|
129
|
+
this.clearTimer(applianceId);
|
|
130
|
+
const delayMs = Math.max(0, validUntilMs - this.now());
|
|
131
|
+
this.timers.set(applianceId, setTimeout(() => {
|
|
132
|
+
this.timers.delete(applianceId);
|
|
133
|
+
const state = this.sessions.get(applianceId);
|
|
134
|
+
if (!state || state.validUntilMs > this.now())
|
|
135
|
+
return;
|
|
136
|
+
this.sessions.delete(applianceId);
|
|
137
|
+
this.onExpired(applianceId);
|
|
138
|
+
}, delayMs));
|
|
139
|
+
}
|
|
140
|
+
clearTimer(applianceId) {
|
|
141
|
+
const timer = this.timers.get(applianceId);
|
|
142
|
+
if (timer !== undefined)
|
|
143
|
+
clearTimeout(timer);
|
|
144
|
+
this.timers.delete(applianceId);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
exports.HeatpumpControlSessionTracker = HeatpumpControlSessionTracker;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { EnyoDataBusHeatpumpControlV3, EnyoHeatpumpControlAction } from '../../types/enyo-data-bus-value.cjs';
|
|
2
|
+
/**
|
|
3
|
+
* Why a `HeatpumpControlV3` session ended — passed to
|
|
4
|
+
* `HeatpumpIntegrationEnergyApp.onHeatpumpControlEnded`.
|
|
5
|
+
*/
|
|
6
|
+
export declare enum HeatpumpControlEndCauseEnum {
|
|
7
|
+
/** `validUntilIso` passed without a newer message (the energy manager stopped repeating it). */
|
|
8
|
+
Expired = "expired",
|
|
9
|
+
/** The energy manager sent a `HeatpumpControlV3` with no actions and no power. */
|
|
10
|
+
Ended = "ended",
|
|
11
|
+
/** The energy manager cancelled the session with `HeatpumpControlCancelV3`. */
|
|
12
|
+
Cancelled = "cancelled"
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The state the SDK keeps for one heatpump's current `HeatpumpControlV3`
|
|
16
|
+
* session.
|
|
17
|
+
*/
|
|
18
|
+
export interface HeatpumpControlSessionState {
|
|
19
|
+
/** The session's `sessionId`. */
|
|
20
|
+
sessionId: string;
|
|
21
|
+
/** A stable key of the session's actions — changes when any action changes. */
|
|
22
|
+
actionsKey: string;
|
|
23
|
+
/** The latest `validUntilIso`, as epoch milliseconds. */
|
|
24
|
+
validUntilMs: number;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Key that identifies an action within a session — its target and zone index.
|
|
28
|
+
*
|
|
29
|
+
* @param action - The action.
|
|
30
|
+
* @returns E.g. `dhw:0`, or `room:` for an action without index.
|
|
31
|
+
*/
|
|
32
|
+
export declare function heatpumpControlActionKey(action: Pick<EnyoHeatpumpControlAction, 'target' | 'index'>): string;
|
|
33
|
+
/**
|
|
34
|
+
* Stable key of a whole action list, independent of the order the actions
|
|
35
|
+
* were sent in. Two lists with the same key ask for the same thing.
|
|
36
|
+
*
|
|
37
|
+
* @param actions - The session's actions.
|
|
38
|
+
* @returns A string that changes whenever any action's target, index,
|
|
39
|
+
* `deltaK` or `heatSource` changes.
|
|
40
|
+
*/
|
|
41
|
+
export declare function heatpumpControlActionsKey(actions: EnyoHeatpumpControlAction[]): string;
|
|
42
|
+
/**
|
|
43
|
+
* Tracks the `HeatpumpControlV3` session of each heatpump: which session is
|
|
44
|
+
* running, which of its actions have been applied, and when it lapses.
|
|
45
|
+
*
|
|
46
|
+
* Rules:
|
|
47
|
+
* - Same `sessionId` and same actions → the session continues; only
|
|
48
|
+
* {@link HeatpumpControlSessionState.validUntilMs} moves.
|
|
49
|
+
* - New `sessionId` or changed actions → a fresh state; every action is
|
|
50
|
+
* applied anew.
|
|
51
|
+
* - No new message by `validUntilIso` → the state is dropped and `onExpired`
|
|
52
|
+
* is called for that appliance.
|
|
53
|
+
*
|
|
54
|
+
* Used by `HeatpumpIntegrationEnergyApp`; exported so integrations that
|
|
55
|
+
* override `handleHeatpumpControl` can reuse the same rules.
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* ```typescript
|
|
59
|
+
* const tracker = new HeatpumpControlSessionTracker((applianceId) => resetHeatpump(applianceId));
|
|
60
|
+
* const {state, isNew} = tracker.track(message.applianceId, message.data);
|
|
61
|
+
* if (isNew) applyActions(message.data.actions);
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
64
|
+
export declare class HeatpumpControlSessionTracker {
|
|
65
|
+
private readonly onExpired;
|
|
66
|
+
private readonly now;
|
|
67
|
+
private readonly sessions;
|
|
68
|
+
private readonly timers;
|
|
69
|
+
/**
|
|
70
|
+
* @param onExpired - Called when an appliance's session lapses because no
|
|
71
|
+
* message arrived by its `validUntilIso`. Not called by {@link end}.
|
|
72
|
+
* @param now - Clock in epoch milliseconds; injectable for tests.
|
|
73
|
+
*/
|
|
74
|
+
constructor(onExpired: (applianceId: string) => void, now?: () => number);
|
|
75
|
+
/**
|
|
76
|
+
* The current session state of an appliance, if one is running.
|
|
77
|
+
*
|
|
78
|
+
* @param applianceId - The heatpump appliance.
|
|
79
|
+
* @returns The state, or `undefined`.
|
|
80
|
+
*/
|
|
81
|
+
get(applianceId: string): HeatpumpControlSessionState | undefined;
|
|
82
|
+
/**
|
|
83
|
+
* Records a received session message and (re)arms its expiry.
|
|
84
|
+
*
|
|
85
|
+
* @param applianceId - The heatpump appliance the message is for.
|
|
86
|
+
* @param data - The message's data.
|
|
87
|
+
* @returns The appliance's state, and `isNew` when the session or its
|
|
88
|
+
* actions changed — the moment to apply the actions anew.
|
|
89
|
+
* @throws {Error} When `validUntilIso` is not an ISO 8601 timestamp.
|
|
90
|
+
*/
|
|
91
|
+
track(applianceId: string, data: EnyoDataBusHeatpumpControlV3['data']): {
|
|
92
|
+
state: HeatpumpControlSessionState;
|
|
93
|
+
isNew: boolean;
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* Ends an appliance's session without calling `onExpired` — for an
|
|
97
|
+
* explicit end the caller handles itself.
|
|
98
|
+
*
|
|
99
|
+
* @param applianceId - The heatpump appliance.
|
|
100
|
+
* @returns `true` when a session was running.
|
|
101
|
+
*/
|
|
102
|
+
end(applianceId: string): boolean;
|
|
103
|
+
/** Drops every session and timer. Call on shutdown. */
|
|
104
|
+
dispose(): void;
|
|
105
|
+
private armExpiry;
|
|
106
|
+
private clearTimer;
|
|
107
|
+
}
|