@enyo-energy/energy-app-sdk 1.30.0 → 1.32.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 (50) hide show
  1. package/README.md +109 -3
  2. package/dist/cjs/energy-app-package-definition.d.cts +142 -0
  3. package/dist/cjs/implementations/heatpump/heatpump-control-session.cjs +147 -0
  4. package/dist/cjs/implementations/heatpump/heatpump-control-session.d.cts +107 -0
  5. package/dist/cjs/implementations/heatpump/heatpump-control-validators.cjs +198 -0
  6. package/dist/cjs/implementations/heatpump/heatpump-control-validators.d.cts +123 -0
  7. package/dist/cjs/implementations/heatpump/heatpump-flexibility-validators.cjs +163 -0
  8. package/dist/cjs/implementations/heatpump/heatpump-flexibility-validators.d.cts +61 -0
  9. package/dist/cjs/implementations/heatpump/heatpump-metadata-validators.cjs +44 -0
  10. package/dist/cjs/implementations/heatpump/heatpump-metadata-validators.d.cts +10 -1
  11. package/dist/cjs/implementations/onboarding-v2/package-onboarding-guide-validators.cjs +213 -0
  12. package/dist/cjs/implementations/onboarding-v2/package-onboarding-guide-validators.d.cts +75 -0
  13. package/dist/cjs/index.cjs +4 -0
  14. package/dist/cjs/index.d.cts +4 -0
  15. package/dist/cjs/integrations/heatpump-integration-energy-app.cjs +400 -2
  16. package/dist/cjs/integrations/heatpump-integration-energy-app.d.cts +248 -2
  17. package/dist/cjs/integrations/integration-energy-app.cjs +4 -2
  18. package/dist/cjs/integrations/integration-energy-app.d.cts +2 -1
  19. package/dist/cjs/integrations/integration-types.d.cts +8 -1
  20. package/dist/cjs/types/enyo-data-bus-value.cjs +83 -3
  21. package/dist/cjs/types/enyo-data-bus-value.d.cts +579 -5
  22. package/dist/cjs/types/enyo-heatpump-appliance.cjs +58 -2
  23. package/dist/cjs/types/enyo-heatpump-appliance.d.cts +81 -2
  24. package/dist/cjs/version.cjs +1 -1
  25. package/dist/cjs/version.d.cts +1 -1
  26. package/dist/energy-app-package-definition.d.ts +142 -0
  27. package/dist/implementations/heatpump/heatpump-control-session.d.ts +107 -0
  28. package/dist/implementations/heatpump/heatpump-control-session.js +141 -0
  29. package/dist/implementations/heatpump/heatpump-control-validators.d.ts +123 -0
  30. package/dist/implementations/heatpump/heatpump-control-validators.js +192 -0
  31. package/dist/implementations/heatpump/heatpump-flexibility-validators.d.ts +61 -0
  32. package/dist/implementations/heatpump/heatpump-flexibility-validators.js +159 -0
  33. package/dist/implementations/heatpump/heatpump-metadata-validators.d.ts +10 -1
  34. package/dist/implementations/heatpump/heatpump-metadata-validators.js +45 -1
  35. package/dist/implementations/onboarding-v2/package-onboarding-guide-validators.d.ts +75 -0
  36. package/dist/implementations/onboarding-v2/package-onboarding-guide-validators.js +207 -0
  37. package/dist/index.d.ts +4 -0
  38. package/dist/index.js +4 -0
  39. package/dist/integrations/heatpump-integration-energy-app.d.ts +248 -2
  40. package/dist/integrations/heatpump-integration-energy-app.js +398 -1
  41. package/dist/integrations/integration-energy-app.d.ts +2 -1
  42. package/dist/integrations/integration-energy-app.js +4 -2
  43. package/dist/integrations/integration-types.d.ts +8 -1
  44. package/dist/types/enyo-data-bus-value.d.ts +579 -5
  45. package/dist/types/enyo-data-bus-value.js +82 -2
  46. package/dist/types/enyo-heatpump-appliance.d.ts +81 -2
  47. package/dist/types/enyo-heatpump-appliance.js +57 -1
  48. package/dist/version.d.ts +1 -1
  49. package/dist/version.js +1 -1
  50. package/package.json +1 -1
package/README.md CHANGED
@@ -33,6 +33,7 @@ The official TypeScript SDK for building Energy Apps on the enyo platform. Creat
33
33
  - [NetworkDeviceManager](#networkdevicemanager)
34
34
  - [Startup pattern](#startup-pattern)
35
35
  - [Package Files](#package-files)
36
+ - [Printable Manual Setup Guide](#printable-manual-setup-guide)
36
37
  - [Firmware Update Registry](#firmware-update-registry)
37
38
  - [Declaring firmware](#declaring-firmware)
38
39
  - [Firmware modes](#firmware-modes)
@@ -2851,6 +2852,43 @@ It checks that names are unique slugs, that paths stay inside the package and ca
2851
2852
  extension, and that any declared MIME type is well formed; two names pointing at one
2852
2853
  path is a warning.
2853
2854
 
2855
+ ## Printable Manual Setup Guide
2856
+
2857
+ A package can declare **one optional** onboarding guide (v2) as static data in its package
2858
+ definition. The enyo CLI reads it during `enyo release`, and enyo renders it as a printable
2859
+ PDF installation manual. It is separate from the interactive guides an app returns from
2860
+ `useOnboardingV2().registerOnboardingGuidesHandler()`: it never takes part in an onboarding
2861
+ run, so it needs no `name` and doesn't conflict with those guides in any way.
2862
+
2863
+ Paper cannot scan the network or ask the app anything, so the guide must describe a
2864
+ **manual setup**:
2865
+
2866
+ - `startVariant: EnyoOnboardingV2StartVariant.ManualSetup` and `requiresNetworkScan: false`
2867
+ - no `network-scan` / `device-test` actions, no `device-select` / `eebus-device-select`
2868
+ blocks, no `dynamic` blocks, no `start-variant` targets
2869
+ - no `applianceId`, `vendorId` or `modelIds`
2870
+ - plain JSON only, and every image block `file` declared in `files`
2871
+
2872
+ ```typescript
2873
+ import {
2874
+ defineEnergyAppPackage,
2875
+ definePublicFile,
2876
+ validatePackageOnboardingGuide
2877
+ } from '@enyo-energy/energy-app-sdk';
2878
+ import {manualSetupGuide} from './src/onboarding/manual-setup.guide.js';
2879
+
2880
+ const packageDef = defineEnergyAppPackage({
2881
+ // ...
2882
+ files: [definePublicFile({name: 'modbus-menu', path: './assets/modbus-menu.png'})],
2883
+ onboardingGuide: manualSetupGuide
2884
+ });
2885
+
2886
+ const {ok, errors, warnings} = validatePackageOnboardingGuide(packageDef);
2887
+ ```
2888
+
2889
+ The complete authoring guide, with the reason behind each rule and a full example, is the
2890
+ JSDoc of `EnergyAppPackageDefinition.onboardingGuide`.
2891
+
2854
2892
  ## Firmware Update Registry
2855
2893
 
2856
2894
  Ship firmware images with your package and hand them to devices at runtime. You declare the files by local path in the package definition, the enyo CLI uploads them during `enyo release`, and the app reaches them through `energyApp.useFirmwareRegistry()`. The release tarball never carries the bytes.
@@ -3110,13 +3148,76 @@ class MyHeatpumpApp extends HeatpumpIntegrationEnergyApp {
3110
3148
 
3111
3149
  Drives a heatpump. Manages building / DHW overheating commands and grid-power-availability announcements.
3112
3150
 
3113
- - **Subscribed commands:** `HeatpumpOverheatingV1`, `HeatpumpAvailablePowerAnnouncementV1`, `SetHeatpumpAvailablePowerV2`, `SetHeatpumpRoomTemperatureV1`, `GridOperatorPowerLimitationV1` (broadcast)
3151
+ > **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
+
3153
+ - **Subscribed commands:** `HeatpumpControlV3`, `HeatpumpControlCancelV3`, `HeatpumpOverheatingV1` (deprecated), `HeatpumpAvailablePowerAnnouncementV1` (deprecated), `SetHeatpumpAvailablePowerV2` (deprecated), `SetHeatpumpRoomTemperatureV1`, `GridOperatorPowerLimitationV1` (broadcast)
3114
3154
  - **Implement:** `handleHeatpumpOverheating`, `handleHeatpumpAvailablePowerAnnouncement`, `handleSetHeatpumpAvailablePower`, `handleGridOperatorPowerLimitation`
3115
- - **Optional override:** `handleSetHeatpumpRoomTemperature` — write a measured room temperature into a heating circuit. Answers `NotSupported` by default; override it when the heatpump declares `RoomTemperatureInput`.
3155
+ - **Optional overrides:**
3156
+ - `handleSetHeatpumpRoomTemperature` — write a measured room temperature into a heating circuit. Answers `NotSupported` by default; override it when the heatpump declares `RoomTemperatureInput`.
3157
+ - `handleHeatpumpControl` — support `HeatpumpControlV3` (see below). Answers `NotSupported` by default; override it and declare `ControlV3`.
3158
+ - `handleHeatpumpControlCancel` — withdraw a cancelled session on the device. By default `Accepted` for heatpumps declaring `ControlV3`, `NotSupported` otherwise.
3159
+ - `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
+ - `resolveHeatpumpMetadata(applianceId)` — the metadata actions are validated against. Defaults to the stored appliance's `heatpump` metadata.
3161
+ - **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
3162
  - **Publish helpers:**
3163
+ - `publishHeatpumpControlStatus(applianceId, {sessionId, actions})` — how a session's actions progress (`HeatpumpControlStatusV3`).
3164
+ - `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
3165
  - `publishHeatpumpValuesUpdate(applianceId, values)` — operation mode, electrical and thermal power, energies.
3118
3166
  - `publishHeatpumpTemperatures(applianceId, temperatures)` — outdoor, flow / target flow / return, DHW tanks, heating circuits (room, flow, target flow, return), buffer tank.
3119
3167
 
3168
+ #### HeatpumpControlV3
3169
+
3170
+ **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`.
3171
+
3172
+ 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`).
3173
+
3174
+ ```typescript
3175
+ {
3176
+ sessionId: 'night-tariff-2026-10-15', // the same across every repeat
3177
+ validUntilIso: '2026-10-15T00:01:00.000Z', // liveness: repeated ~every 10 s, ~60 s ahead
3178
+ availablePowerW: 3000,
3179
+ powerSources: [{ source: EnyoPowerSourceEnum.Grid, powerW: 3000 }],
3180
+ actions: [{ target: EnyoHeatpumpControlTargetEnum.Dhw, index: 0, deltaK: 5 }],
3181
+ }
3182
+ ```
3183
+
3184
+ - **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.
3185
+ - **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`.
3186
+ - **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`.
3187
+ - **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`).
3188
+ - **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.
3189
+ - **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`).
3190
+ - **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.
3191
+ - **Validation** (`checkHeatpumpControlActions(message)`, or `validateHeatpumpControlAction(action, metadata, powerSources, offer?)` directly):
3192
+ - A heating rod (`heatSource` `HeatingRod` / `Both`) runs on PV surplus only: rejected unless `powerSources` are PV alone, and `NotSupported` without `HeatingRodControllable`.
3193
+ - Each target needs its overheating feature (`DomesticHotWaterOverheating`, `BufferTankOverheating`, `RoomOverheating`), else `NotSupported`.
3194
+ - DHW must stay within `domesticHotWater[].maxTemperatureC`: setpoint + `deltaK` above it is rejected.
3195
+ - When the session names an `offerId`, each action must fit the heatpump's current offer.
3196
+
3197
+ #### HeatpumpFlexibilityAnnouncementV3
3198
+
3199
+ 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`.
3200
+
3201
+ ```typescript
3202
+ const offerId = await this.publishHeatpumpFlexibility('heatpump-1', [{
3203
+ target: EnyoHeatpumpControlTargetEnum.Dhw,
3204
+ index: 0,
3205
+ currentTemperatureC: 47,
3206
+ setpointC: 50,
3207
+ maxDeltaK: 10,
3208
+ heatSources: [
3209
+ { heatSource: EnyoHeatpumpHeatSourceEnum.Compressor, power: { minWatt: 800, maxWatt: 2300, stepWatt: 100 }, estimatedElectricalKwh: 1.4 },
3210
+ { heatSource: EnyoHeatpumpHeatSourceEnum.HeatingRod, power: { minWatt: 3000, maxWatt: 3000, stepWatt: 3000 }, estimatedElectricalKwh: 4.1 },
3211
+ ],
3212
+ required: { deltaK: 3, byIso: '2026-10-14T18:00:00.000Z' },
3213
+ }]);
3214
+ ```
3215
+
3216
+ - **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.
3217
+ - **`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.
3218
+ - **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.
3219
+ - **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.
3220
+
3120
3221
  #### Advanced heatpump metadata
3121
3222
 
3122
3223
  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 +3228,11 @@ Beyond tanks and circuits, the heatpump metadata (`EnyoHeatpumpApplianceMetadata
3127
3228
  | `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
3229
  | `RoomTemperature` | `heatingCircuits[].roomTemperatureC` in `HeatpumpTemperaturesUpdateV1`; where it is measured goes in `heatingCircuits[].roomTemperatureSource` (`None` / `Internal` / `External`) and, for an enyo sensor, `roomTemperatureSensor` |
3129
3230
  | `RoomTemperatureInput` | The heatpump accepts measured room temperatures via `SetHeatpumpRoomTemperatureV1` |
3231
+ | `PvSurplusSignal` | The heatpump takes `availablePowerW` of `HeatpumpControlV3` as a PV surplus signal (e.g. Lambda register 102). Supersedes the deprecated `AvailablePowerAnnouncement` |
3232
+ | `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 |
3233
+ | `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 |
3234
+ | — | `heatingCircuits[].heatingControlReference` — what the circuit steers by: `Outdoor` (heating curve) or `Room`. Independent of `roomTemperatureSource`, which says where the room is measured |
3235
+ | — | `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
3236
  | `FlowTemperature` / `ReturnTemperature` | `heatpumpFlowTemperatureC`, `heatpumpTargetFlowTemperatureC`, `heatpumpReturnTemperatureC` and per-circuit `flowTemperatureC`, `targetFlowTemperatureC`, `returnTemperatureC` in `HeatpumpTemperaturesUpdateV1` |
3131
3237
 
3132
3238
  `updateAppliance` replaces the whole `heatingCircuits` / `domesticHotWater` array, so use the `ApplianceManager` helpers to change a single entry:
@@ -4211,7 +4317,7 @@ Each event carries the **complete** picture of the slot — render it as it arri
4211
4317
 
4212
4318
  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.
4213
4319
 
4214
- **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.
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.
4215
4321
 
4216
4322
  **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 %)."
4217
4323
 
@@ -1,6 +1,7 @@
1
1
  import { EnergyAppPermissionType } from "./energy-app-permission.type.cjs";
2
2
  import { EnergyAppModelFeatureEnum } from "./energy-app-model-feature.enum.cjs";
3
3
  import type { EnyoFileTranslation } from "./types/enyo-file.cjs";
4
+ import type { EnyoOnboardingV2Guide } from "./types/enyo-onboarding-v2.cjs";
4
5
  export type EnergyAppPackageLanguage = 'de' | 'en';
5
6
  export declare enum EnergyAppPackageCategory {
6
7
  Inverter = "inverter",
@@ -658,6 +659,147 @@ export interface EnergyAppPackageDefinition {
658
659
  * Only relevant when {@link firmware} is declared.
659
660
  */
660
661
  firmwareMode?: EnergyAppPackageFirmwareMode;
662
+ /**
663
+ * The package's static **manual setup** onboarding guide (v2). It is
664
+ * optional and there is at most one. The enyo CLI reads it at release time
665
+ * and ships it with the package version, where enyo renders it as a
666
+ * printable PDF installation manual.
667
+ *
668
+ * ## What it is for
669
+ *
670
+ * The guides an app returns from
671
+ * {@link EnergyAppOnboardingV2.registerOnboardingGuidesHandler} are
672
+ * interactive and only exist on a device where the app is running. This
673
+ * guide is the opposite: a fixed document that exists as soon as the
674
+ * package is released. An installer can read it on paper before the
675
+ * device is even powered. It is written with the same v2 guide model, so
676
+ * an app that already has an interactive manual setup flow can usually
677
+ * declare the same steps here.
678
+ *
679
+ * It does not take part in onboarding runs. It is never offered by the
680
+ * host, never addressed by name and never compared against the guides from
681
+ * the runtime handler, so there are no naming or precedence rules between
682
+ * the two.
683
+ *
684
+ * ## What it may contain
685
+ *
686
+ * Paper cannot scan the network or ask the app anything, so the guide is
687
+ * limited to a manual setup the installer can follow by reading. The
688
+ * following rules are enforced by `validatePackageOnboardingGuide()`, and
689
+ * the CLI refuses the release if any of them is broken:
690
+ *
691
+ * | Rule | Why |
692
+ * |---|---|
693
+ * | `startVariant` is {@link EnyoOnboardingV2StartVariant.ManualSetup} | The document describes a manual setup, nothing else. |
694
+ * | `requiresNetworkScan: false` is set explicitly | The default is `true`, and a printed manual has no scan to rely on. |
695
+ * | No `network-scan` or `device-test` action blocks | Both act on devices found by a scan. |
696
+ * | No `device-select` or `eebus-device-select` blocks | Both pick from devices the host discovered. |
697
+ * | No `dynamic` blocks | Their value is resolved by the app at runtime and does not exist when the PDF is rendered. |
698
+ * | No `start-variant` targets | They lead into another, scan-based guide that is not part of the document. |
699
+ * | No `applianceId`, `vendorId` or `modelIds` | A document is not bound to an appliance, and enyo derives vendor and models from the package. |
700
+ * | Plain JSON only (no functions, `Date`s, `undefined` in arrays, class instances) | The CLI serializes the guide into the release. |
701
+ * | Every image block `file` is declared in {@link files} | Otherwise the PDF has a hole where the image belongs. |
702
+ *
703
+ * Everything else is allowed. Content blocks (text, headline, bullets,
704
+ * image, hint, link) render as they are. Interactive blocks (choice,
705
+ * select, input, credentials, auth, additional-setup and the
706
+ * `connection-check` / `ocpp-connect` actions) render as instructions,
707
+ * and their outcomes and transitions become the branches of the document
708
+ * ("if the device is not reachable, continue with …"). Give every
709
+ * outcome a label that reads well on paper.
710
+ *
711
+ * `name` is optional and has no meaning here.
712
+ *
713
+ * ## How to write one
714
+ *
715
+ * 1. Put the guide in its own file (for example
716
+ * `src/onboarding/manual-setup.guide.ts`) and build it with
717
+ * `defineOnboardingGuideV2()`, the `onboardingV2Block.*` and
718
+ * `onboardingV2Target.*` factories, and the `onContinueV2()` /
719
+ * `onOutcomeV2()` transition helpers.
720
+ * 2. Keep the file free of runtime dependencies. The CLI loads it while
721
+ * loading this definition, outside the app sandbox, so it must not
722
+ * touch the SDK instance, storage, the network or environment state.
723
+ * 3. Import it here and assign it to `onboardingGuide`.
724
+ * 4. Check it locally with `validatePackageOnboardingGuide(definition)`.
725
+ * 5. Release. The CLI validates the guide again and aborts on errors.
726
+ *
727
+ * ```typescript
728
+ * // src/onboarding/manual-setup.guide.ts
729
+ * import {
730
+ * defineOnboardingGuideV2,
731
+ * EnyoDeviceTestOutcomeEnum,
732
+ * EnyoOnboardingV2InputValueType,
733
+ * EnyoOnboardingV2StartVariant,
734
+ * onboardingV2Block,
735
+ * onboardingV2Target,
736
+ * onContinueV2,
737
+ * onOutcomeV2,
738
+ * } from '@enyo-energy/energy-app-sdk';
739
+ *
740
+ * const t = (de: string, en: string) => [
741
+ * {language: 'de' as const, value: de},
742
+ * {language: 'en' as const, value: en},
743
+ * ];
744
+ *
745
+ * export const manualSetupGuide = defineOnboardingGuideV2({
746
+ * title: t('Wechselrichter manuell einrichten', 'Set up the inverter manually'),
747
+ * startVariant: EnyoOnboardingV2StartVariant.ManualSetup,
748
+ * requiresNetworkScan: false,
749
+ * startStepId: 'prepare',
750
+ * steps: [
751
+ * {
752
+ * id: 'prepare',
753
+ * name: 'modbus-aktivieren',
754
+ * title: t('Modbus TCP aktivieren', 'Enable Modbus TCP'),
755
+ * blocks: [
756
+ * onboardingV2Block.text('intro', t(
757
+ * 'Aktiviere Modbus TCP im Webinterface des Wechselrichters.',
758
+ * 'Enable Modbus TCP in the inverter web interface.',
759
+ * )),
760
+ * onboardingV2Block.imageFile('menu', 'modbus-menu', t('Menü', 'Menu')),
761
+ * ],
762
+ * transitions: [onContinueV2(onboardingV2Target.step('ip'))],
763
+ * },
764
+ * {
765
+ * id: 'ip',
766
+ * name: 'ip-eingeben',
767
+ * title: t('IP-Adresse eingeben', 'Enter the IP address'),
768
+ * blocks: [
769
+ * onboardingV2Block.input(
770
+ * 'device-ip',
771
+ * EnyoOnboardingV2InputValueType.IpAddress,
772
+ * t('IP-Adresse des Wechselrichters', 'Inverter IP address'),
773
+ * t('Verbindung prüfen', 'Check connection'),
774
+ * [
775
+ * {id: 'ok', value: EnyoDeviceTestOutcomeEnum.AppliancesCreated, label: t('Verbunden', 'Connected')},
776
+ * {id: 'unreachable', value: EnyoDeviceTestOutcomeEnum.Unreachable, label: t('Nicht erreichbar', 'Unreachable')},
777
+ * ],
778
+ * {placeholder: t('z. B. 192.168.1.42', 'e.g. 192.168.1.42')},
779
+ * ),
780
+ * ],
781
+ * transitions: [
782
+ * onOutcomeV2('device-ip', 'ok', onboardingV2Target.success()),
783
+ * onOutcomeV2('device-ip', 'unreachable', onboardingV2Target.step('prepare')),
784
+ * ],
785
+ * },
786
+ * ],
787
+ * });
788
+ *
789
+ * // energy-app.package.ts
790
+ * import {defineEnergyAppPackage, definePublicFile} from '@enyo-energy/energy-app-sdk';
791
+ * import {manualSetupGuide} from './src/onboarding/manual-setup.guide.js';
792
+ *
793
+ * export default defineEnergyAppPackage({
794
+ * // ...
795
+ * files: [definePublicFile({name: 'modbus-menu', path: './assets/modbus-menu.png'})],
796
+ * onboardingGuide: manualSetupGuide,
797
+ * });
798
+ * ```
799
+ *
800
+ * Omit the field for packages that have no manual setup to document.
801
+ */
802
+ onboardingGuide?: EnyoOnboardingV2Guide;
661
803
  }
662
804
  /**
663
805
  * Defines an Energy App package with automatic SDK version injection.
@@ -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
+ }