@enyo-energy/energy-app-sdk 1.25.0 → 1.27.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 (70) hide show
  1. package/README.md +207 -3
  2. package/dist/cjs/energy-app-model-feature.enum.cjs +2 -0
  3. package/dist/cjs/energy-app-model-feature.enum.d.cts +2 -0
  4. package/dist/cjs/energy-app-package-definition.cjs +29 -1
  5. package/dist/cjs/energy-app-package-definition.d.cts +77 -0
  6. package/dist/cjs/energy-app.cjs +17 -0
  7. package/dist/cjs/energy-app.d.cts +16 -0
  8. package/dist/cjs/enyo-energy-app-sdk.d.cts +3 -0
  9. package/dist/cjs/implementations/appliances/appliance-manager.cjs +72 -0
  10. package/dist/cjs/implementations/appliances/appliance-manager.d.cts +37 -1
  11. package/dist/cjs/implementations/heatpump/heatpump-metadata-validators.cjs +329 -0
  12. package/dist/cjs/implementations/heatpump/heatpump-metadata-validators.d.cts +105 -0
  13. package/dist/cjs/index.cjs +3 -0
  14. package/dist/cjs/index.d.cts +3 -0
  15. package/dist/cjs/integrations/heatpump-integration-energy-app.cjs +29 -1
  16. package/dist/cjs/integrations/heatpump-integration-energy-app.d.cts +24 -2
  17. package/dist/cjs/integrations/integration-energy-app.cjs +36 -0
  18. package/dist/cjs/integrations/integration-energy-app.d.cts +26 -1
  19. package/dist/cjs/packages/energy-app-cascade.cjs +2 -0
  20. package/dist/cjs/packages/energy-app-cascade.d.cts +374 -0
  21. package/dist/cjs/packages/energy-app-electricity-tariff.d.cts +41 -3
  22. package/dist/cjs/types/enyo-appliance.cjs +8 -0
  23. package/dist/cjs/types/enyo-appliance.d.cts +9 -1
  24. package/dist/cjs/types/enyo-data-bus-value.cjs +112 -2
  25. package/dist/cjs/types/enyo-data-bus-value.d.cts +518 -6
  26. package/dist/cjs/types/enyo-electricity-tariff.cjs +12 -1
  27. package/dist/cjs/types/enyo-electricity-tariff.d.cts +42 -0
  28. package/dist/cjs/types/enyo-forecasting.d.cts +36 -5
  29. package/dist/cjs/types/enyo-heatpump-appliance.cjs +69 -1
  30. package/dist/cjs/types/enyo-heatpump-appliance.d.cts +246 -3
  31. package/dist/cjs/types/enyo-meter-cascade.cjs +49 -0
  32. package/dist/cjs/types/enyo-meter-cascade.d.cts +229 -0
  33. package/dist/cjs/types/enyo-weather-history.d.cts +35 -4
  34. package/dist/cjs/version.cjs +1 -1
  35. package/dist/cjs/version.d.cts +1 -1
  36. package/dist/energy-app-model-feature.enum.d.ts +2 -0
  37. package/dist/energy-app-model-feature.enum.js +2 -0
  38. package/dist/energy-app-package-definition.d.ts +77 -0
  39. package/dist/energy-app-package-definition.js +28 -0
  40. package/dist/energy-app.d.ts +16 -0
  41. package/dist/energy-app.js +17 -0
  42. package/dist/enyo-energy-app-sdk.d.ts +3 -0
  43. package/dist/implementations/appliances/appliance-manager.d.ts +37 -1
  44. package/dist/implementations/appliances/appliance-manager.js +72 -0
  45. package/dist/implementations/heatpump/heatpump-metadata-validators.d.ts +105 -0
  46. package/dist/implementations/heatpump/heatpump-metadata-validators.js +320 -0
  47. package/dist/index.d.ts +3 -0
  48. package/dist/index.js +3 -0
  49. package/dist/integrations/heatpump-integration-energy-app.d.ts +24 -2
  50. package/dist/integrations/heatpump-integration-energy-app.js +30 -2
  51. package/dist/integrations/integration-energy-app.d.ts +26 -1
  52. package/dist/integrations/integration-energy-app.js +36 -0
  53. package/dist/packages/energy-app-cascade.d.ts +374 -0
  54. package/dist/packages/energy-app-cascade.js +1 -0
  55. package/dist/packages/energy-app-electricity-tariff.d.ts +41 -3
  56. package/dist/types/enyo-appliance.d.ts +9 -1
  57. package/dist/types/enyo-appliance.js +8 -0
  58. package/dist/types/enyo-data-bus-value.d.ts +518 -6
  59. package/dist/types/enyo-data-bus-value.js +111 -1
  60. package/dist/types/enyo-electricity-tariff.d.ts +42 -0
  61. package/dist/types/enyo-electricity-tariff.js +11 -0
  62. package/dist/types/enyo-forecasting.d.ts +36 -5
  63. package/dist/types/enyo-heatpump-appliance.d.ts +246 -3
  64. package/dist/types/enyo-heatpump-appliance.js +68 -0
  65. package/dist/types/enyo-meter-cascade.d.ts +229 -0
  66. package/dist/types/enyo-meter-cascade.js +46 -0
  67. package/dist/types/enyo-weather-history.d.ts +35 -4
  68. package/dist/version.d.ts +1 -1
  69. package/dist/version.js +1 -1
  70. package/package.json +1 -1
package/README.md CHANGED
@@ -191,6 +191,7 @@ The SDK exposes several layered building blocks. Pick the one that matches the k
191
191
  | Read EPEX SPOT wholesale prices (incl. negative-price windows) | [`useEpexSpotPrices()`](#useepexspotprices-energyappepexspotprice) |
192
192
  | Manage electricity tariffs (default tariff, price per kWh) | [`useElectricityTariff()`](#useelectricitytariff-energyappelectricitytariff) |
193
193
  | Publish or resolve time-variable grid fees (§14a HT/NT) | [`useGridFee()`](#usegridfee-energyappgridfee) |
194
+ | Price appliances behind a cascade meter (own tariff, prices and grid fee) | [`useCascade()`](#usecascade-energyappcascade) |
194
195
  | Register a PV system (kWp, DC strings, orientation) | [`usePvSystem()`](#usepvsystem-energyapppvsystem) |
195
196
  | Discover capabilities of the active energy manager | [`useEnergyManager()`](#useenergymanager-energyappenergymanager) |
196
197
  | Serve your v2 onboarding guides when the host asks for them | [`useOnboardingV2()`](#useonboardingv2-energyapponboardingv2) |
@@ -249,6 +250,7 @@ Every Energy App must be defined using `defineEnergyAppPackage()`:
249
250
  import {
250
251
  defineEnergyAppPackage,
251
252
  EnergyAppPackageCategory,
253
+ EnergyAppPackageOptionsDeviceDetectionModbusModeEnum,
252
254
  EnergyAppPermissionTypeEnum
253
255
  } from '@enyo-energy/energy-app-sdk';
254
256
 
@@ -291,11 +293,22 @@ const packageDef = defineEnergyAppPackage({
291
293
  },
292
294
  deviceDetection: {
293
295
  modbus: [{
296
+ // Optional, only on the first entry: how the entries and their matching
297
+ // values combine. Default: RegistersOr_MatchingValuesOr.
298
+ mode: EnergyAppPackageOptionsDeviceDetectionModbusModeEnum.RegistersAnd_MatchingValuesOr,
294
299
  unitIds: [1],
295
300
  registerAddress: 40001,
296
301
  registerSize: 2,
297
302
  type: 'string',
298
303
  matchingValues: ['SolarMax', 'SMA']
304
+ }, {
305
+ unitIds: [1],
306
+ // `registerType` defaults to 'holding'; use 'input' for function code 4.
307
+ registerType: 'input',
308
+ registerAddress: 30053,
309
+ registerSize: 2,
310
+ type: 'UInt32BE',
311
+ matchingValues: ['9401', '9402']
299
312
  }],
300
313
  mdns: [{
301
314
  // The Envoy advertises under a vendor-specific service type; without
@@ -1703,6 +1716,18 @@ const prices = await tariffs.getPrices(EnyoTariffDirectionEnum.Consumption, { fr
1703
1716
  const needsGridFee = !prices?.includes.includes(EnyoPriceComponentEnum.GridFee);
1704
1717
  ```
1705
1718
 
1719
+ To price one device, ask for the prices that actually bill it. This works for every appliance: the
1720
+ site's consumption prices, or — for an appliance behind an active [meter cascade](#usecascade-energyappcascade)
1721
+ — the cascade's, filled per 15-minute slot. The prices are effective prices (grid fee and taxes
1722
+ included, see `includes`), so never add a grid fee on top:
1723
+
1724
+ ```typescript
1725
+ const billed = await tariffs.getPricesForAppliance('heatpump-1', { fromIso, untilIso });
1726
+ // billed.billingMeter: 'primary' | 'cascade'
1727
+ // entry.fallback: cascade had no price for this slot, the site's was used
1728
+ // billed.inheritedFromSite: cascade has no tariff of its own yet
1729
+ ```
1730
+
1706
1731
  The app that integrates a provider owns the other side. It answers when the user picks it, sets the
1707
1732
  tariff once it is actually usable, and pushes prices as they arrive:
1708
1733
 
@@ -1773,6 +1798,115 @@ const fees = await gridFee.getGridFeeValues({ fromIso, untilIso });
1773
1798
 
1774
1799
  Publishers need `GridFeeRegister`; consumers need `GridFeeUse`.
1775
1800
 
1801
+ #### `useCascade(): EnergyAppCascade`
1802
+
1803
+ A **meter cascade** (Kaskadenschaltung) is a second billing meter (Z2) installed *behind* the primary
1804
+ meter (Z1) — typically for a heatpump or wallbox on a dedicated tariff with a reduced (§14a EnWG) grid
1805
+ fee. Appliances behind an **active** Z2 are billed on the cascade's tariff and grid fee; everything else
1806
+ on `useElectricityTariff()` and `useGridFee()`. The site has at most one cascade, and Z2 has a
1807
+ **consumption tariff only** — all feed-in leaves through Z1.
1808
+
1809
+ **Split rule.** PV and battery cover Z2 first; Z2 only pays the cascade tariff for what Z1 imports at the
1810
+ same moment. Z1 importing 2 kW, battery 1 kW, Z2 drawing 2 kW → Z2 grid share 1 kW, Z2 self-consumed
1811
+ 1 kW, household grid share 1 kW.
1812
+
1813
+ Topology — is there a cascade, is it working, who is behind it:
1814
+
1815
+ ```typescript
1816
+ const cascade = energyApp.useCascade();
1817
+
1818
+ const details = await cascade.getCascade(); // active, status, estimated, meterApplianceId?, applianceIds
1819
+ if (details?.active && details.status !== EnyoCascadeStatusEnum.Live) {
1820
+ // 'noCascadeSource' (nothing assigned yet) or 'noGridReading' (no Z1 reading)
1821
+ }
1822
+ cascade.onCascadeChanged(event => reassignPrices(event.cascade));
1823
+ ```
1824
+
1825
+ With `estimated: true`, Z2 is the sum of its appliances and has no meter (`meterApplianceId` absent).
1826
+ A physical Z2 meter carries the topology feature `CascadeSubMeter` — skip it when looking for the grid
1827
+ meter.
1828
+
1829
+ Prices — to price a device, use `useElectricityTariff().getPricesForAppliance(applianceId, range)`. It
1830
+ works for every appliance and picks Z2 or Z1 itself, returning the **effective** price per 15-minute slot,
1831
+ grid fee and taxes included. `cascade.getPrices(range)` returns Z2's effective prices directly. Never add
1832
+ a grid fee on top:
1833
+
1834
+ ```typescript
1835
+ const prices = await energyApp.useElectricityTariff().getPricesForAppliance('heatpump-1', { fromIso, untilIso });
1836
+ // prices.billingMeter: 'cascade' | 'primary'
1837
+ // entry.fallback: Z2 had no price for that slot, the site's was used
1838
+ // prices.inheritedFromSite: Z2 has no tariff of its own yet
1839
+ ```
1840
+
1841
+ Until Z2 has its own tariff or grid fee, the hub prices it with the site's — and so do `getTariff`,
1842
+ `getPrices`, `getGridFee` and `getGridFeeValues`: they return the site's values with
1843
+ `inheritedFromSite: true` rather than `null`. The grid fee methods are informational (to show the fee
1844
+ separately).
1845
+
1846
+ What Z2 is actually drawing and costing — live via the `CascadeSplitUpdateV1` data bus message
1847
+ (`householdGridW`, `cascadeGridW`, `cascadeSelfConsumedW`, `subMeterPowerW`, `status`, `estimated` — the
1848
+ same values the cockpit shows), and as history:
1849
+
1850
+ ```typescript
1851
+ energyApp.useDataBus().listenForMessages([EnyoDataBusMessageEnum.CascadeSplitUpdateV1], msg => {
1852
+ const { cascadeGridW, status } = (msg as EnyoDataBusCascadeSplitUpdateV1).data;
1853
+ });
1854
+
1855
+ const history = await cascade.getCascadeTimeseries({ fromIso, untilIso, resolution: '1d' });
1856
+ // resolution: '1m' (kept 31 days) | '15m' | '1d' | '1mo'
1857
+ // per bucket: householdGridKwh, cascadeGridKwh, cascadeSelfConsumedKwh,
1858
+ // householdCostCt, cascadeCostCt, cascadePriceCt, estimated
1859
+ ```
1860
+
1861
+ History costs are in **ct**, priced when read with the tariffs then in force.
1862
+
1863
+ **The core owns the cascade tariff by default** — the user enters it in the hub. The cascade **grid fee
1864
+ comes with the tariff**: it is provided by the core, or by the app that owns the cascade tariff, and goes
1865
+ away with that app's ownership. An app that provides a
1866
+ **dynamic** tariff can offer itself for Z2 and take it over once the user selects it, then publish its
1867
+ prices:
1868
+
1869
+ ```typescript
1870
+ // Registering the handler is what lists this app in the hub's cascade tariff selection.
1871
+ cascade.onTariffSelected(async () => {
1872
+ if (!await isAuthenticated()) {
1873
+ return {
1874
+ status: EnyoTariffActivationStatusEnum.AuthenticationRequired,
1875
+ authenticationUrl: buildOAuthUrl('cascade'),
1876
+ };
1877
+ }
1878
+ await cascade.setTariff({ // takes over the slot from the core
1879
+ name: 'Tibber Wärmepumpe',
1880
+ vendorName: 'Tibber',
1881
+ currency: EnyoCurrencyEnum.EUR,
1882
+ pricing: { type: EnyoTariffPricingTypeEnum.Dynamic }, // only dynamic tariffs can be app-provided
1883
+ externalTariffId: contract.id,
1884
+ });
1885
+ return { status: EnyoTariffActivationStatusEnum.Success };
1886
+ });
1887
+
1888
+ await cascade.publishPrices({ includes: [], entries }); // only while this app owns Z2's tariff
1889
+ await cascade.registerGridFee(reducedGridFee); // optional: the Z2 grid fee comes with the tariff
1890
+
1891
+ cascade.onTariffChanged(event => {
1892
+ if (event.tariff?.externalTariffId !== contract.id) stopPublishing(); // lost ownership
1893
+ });
1894
+ ```
1895
+
1896
+ | Group | Methods | Permission |
1897
+ |---|---|---|
1898
+ | Topology | `isActive`, `getCascade`, `getApplianceIds`, `isApplianceBehindCascade`, `onCascadeChanged` | none |
1899
+ | Prices & tariff (read) | `getTariff`, `getPrices`, `onTariffChanged` | none |
1900
+ | History | `getCascadeTimeseries` | `Timeseries` |
1901
+ | Grid fee | `getGridFee`, `getGridFeeValues`, `onGridFeeChanged` / `registerGridFee`, `removeGridFee` (cascade tariff owner only) | `GridFeeUse` / `GridFeeRegister` |
1902
+ | Dynamic tariff provider | `onTariffSelected`, `setTariff`, `publishPrices` | `ElectricityTariff` |
1903
+ | Data bus | `CascadeSplitUpdateV1`, `AggregatedStateUpdateV1.data.cascade` | `SubscribeDataBus` |
1904
+
1905
+ Without a cascade, topology getters resolve to `null` / `false` / `[]`, cascade price and grid fee
1906
+ getters to `null`, and writes reject; `useElectricityTariff().getPricesForAppliance` returns the site's
1907
+ prices. Z1 also counts
1908
+ everything that passes Z2, so never add the two meter readings together.
1909
+
1776
1910
  #### `useWeatherForecasting(): EnergyAppWeatherForecasting`
1777
1911
 
1778
1912
  Register a weather-forecast provider (e.g. wraps an external API) and / or consume forecasts by zip code or coordinates.
@@ -1860,6 +1994,11 @@ forecast series concatenate into one timeline without translation. Every measure
1860
1994
  optional: you get what the provider holds and you asked for, and a measure it does not hold is simply
1861
1995
  absent rather than an error.
1862
1996
 
1997
+ `timestampIso` is the **start** of the bucket a reading or forecast entry covers. The irradiance
1998
+ fields are the mean over `[timestampIso, timestampIso + resolution)` — not the value at that moment,
1999
+ and not the mean of the preceding hour. Temperature, wind and cloud cover are the value at the start
2000
+ of the bucket for an hourly source, and the time-weighted mean over the bucket at coarser resolutions.
2001
+
1863
2002
  Aggregates per measure live in `statistics`, keyed by `WeatherHistoryMeasureEnum`. `averageValue` is
1864
2003
  time-weighted; for the irradiance measures it is a mean power density in W/m², so multiply by the
1865
2004
  covered duration in hours to get received energy in Wh/m². `symbol` is categorical and therefore
@@ -2954,11 +3093,48 @@ class MyHeatpumpApp extends HeatpumpIntegrationEnergyApp {
2954
3093
 
2955
3094
  Drives a heatpump. Manages building / DHW overheating commands and grid-power-availability announcements.
2956
3095
 
2957
- - **Subscribed commands:** `HeatpumpOverheatingV1`, `HeatpumpAvailablePowerAnnouncementV1`, `GridOperatorPowerLimitationV1` (broadcast)
2958
- - **Implement:** `handleHeatpumpOverheating`, `handleHeatpumpAvailablePowerAnnouncement`, `handleGridOperatorPowerLimitation`
3096
+ - **Subscribed commands:** `HeatpumpOverheatingV1`, `HeatpumpAvailablePowerAnnouncementV1`, `SetHeatpumpAvailablePowerV2`, `SetHeatpumpRoomTemperatureV1`, `GridOperatorPowerLimitationV1` (broadcast)
3097
+ - **Implement:** `handleHeatpumpOverheating`, `handleHeatpumpAvailablePowerAnnouncement`, `handleSetHeatpumpAvailablePower`, `handleGridOperatorPowerLimitation`
3098
+ - **Optional override:** `handleSetHeatpumpRoomTemperature` — write a measured room temperature into a heating circuit. Answers `NotSupported` by default; override it when the heatpump declares `RoomTemperatureInput`.
2959
3099
  - **Publish helpers:**
2960
3100
  - `publishHeatpumpValuesUpdate(applianceId, values)` — operation mode, electrical and thermal power, energies.
2961
- - `publishHeatpumpTemperatures(applianceId, temperatures)` — outdoor, flow, return, DHW tanks, heating circuits, buffer tank.
3101
+ - `publishHeatpumpTemperatures(applianceId, temperatures)` — outdoor, flow / target flow / return, DHW tanks, heating circuits (room, flow, target flow, return), buffer tank.
3102
+
3103
+ #### Advanced heatpump metadata
3104
+
3105
+ 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:
3106
+
3107
+ | Feature flag | Where the data lives |
3108
+ |---|---|
3109
+ | `HeatingCurve` | `heatingCircuits[].heatingCurve` — outdoor → flow temperature `points` (vendor-neutral, linearly interpolated), plus raw `slope` / `parallelShiftK`, min / max flow and an optional cooling curve |
3110
+ | `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` |
3111
+ | `RoomTemperature` | `heatingCircuits[].roomTemperatureC` in `HeatpumpTemperaturesUpdateV1`; where it is measured goes in `heatingCircuits[].roomTemperatureSource` (`None` / `Internal` / `External`) and, for an enyo sensor, `roomTemperatureSensor` |
3112
+ | `RoomTemperatureInput` | The heatpump accepts measured room temperatures via `SetHeatpumpRoomTemperatureV1` |
3113
+ | `FlowTemperature` / `ReturnTemperature` | `heatpumpFlowTemperatureC`, `heatpumpTargetFlowTemperatureC`, `heatpumpReturnTemperatureC` and per-circuit `flowTemperatureC`, `targetFlowTemperatureC`, `returnTemperatureC` in `HeatpumpTemperaturesUpdateV1` |
3114
+
3115
+ `updateAppliance` replaces the whole `heatingCircuits` / `domesticHotWater` array, so use the `ApplianceManager` helpers to change a single entry:
3116
+
3117
+ ```typescript
3118
+ await applianceManager.updateHeatpumpHeatingCircuit(applianceId, 0, {
3119
+ heatingCurve: {
3120
+ points: [
3121
+ { outdoorTemperatureC: -15, flowTemperatureC: 48 },
3122
+ { outdoorTemperatureC: 0, flowTemperatureC: 38 },
3123
+ { outdoorTemperatureC: 15, flowTemperatureC: 27 },
3124
+ ],
3125
+ slope: 0.8,
3126
+ parallelShiftK: 0,
3127
+ },
3128
+ timeProgram: {
3129
+ defaultLevel: EnyoHeatpumpTimeProgramLevelEnum.Reduced,
3130
+ levelTemperaturesC: { Comfort: 21, Reduced: 18 },
3131
+ periods: [{ daysOfWeek: [1, 2, 3, 4, 5], startTimeOfDay: '06:00', endTimeOfDay: '22:00', level: EnyoHeatpumpTimeProgramLevelEnum.Comfort }],
3132
+ },
3133
+ });
3134
+ await applianceManager.updateHeatpumpDomesticHotWater(applianceId, 0, { timeProgram: dhwProgram });
3135
+ ```
3136
+
3137
+ Validate before publishing with `validateHeatpumpHeatingCurve`, `validateHeatpumpTimeProgram` or `validateHeatpumpAdvancedMetadata` (or their `assertValid*` counterparts). Each returns `{ ok, errors, warnings }`.
2962
3138
 
2963
3139
  ### WallboxIntegrationEnergyApp
2964
3140
 
@@ -3993,6 +4169,12 @@ Each event carries the **complete** picture of the slot — render it as it arri
3993
4169
 
3994
4170
  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.
3995
4171
 
4172
+ **Battery vs. charging car.** While a car charges, the house battery is either held out of it or allowed to help, according to the owner's `batteryEvDischargeMode`. Two reason types (category `BatteryState`) say which: `BatteryReservedFromEv` for the hold (`Discharge 0`) and `BatterySupportsEvCharging` for the release (`mode=Auto`). Both carry `context.batteryToEv` (`EnyoDataBusCommandReasonBatteryToEvContext`) with the owner's `mode`, the hold `trigger` (`EnyoBatteryToEvHoldTriggerEnum`: `OwnerBlocked`, `SocLimitReached`, `AllowanceSpent`, `NoMeasurement`), `socLimitPercent`, `allowanceWh` and `remainingWh`. The energy manager re-checks a hold periodically by briefly releasing the battery; that re-check sets `batteryToEv.probe: true` and should not be listed as its own entry in a command history.
4173
+
4174
+ **Short-cycling protection.** When an appliance is kept running (or kept off) to honour its minimum on/off time, state `DeviceProtection` with `context.switching` (`EnyoDataBusCommandReasonSwitchingContext`): `hold` (`EnyoSwitchingProtectionHoldEnum.KeptOn` / `KeptOff`), `minOnSeconds`, `minOffSeconds` and `untilIso`. The text can then say "Kept running for another 4 minutes to protect the compressor" instead of a generic "Protecting the device".
4175
+
4176
+ A hold that keeps an appliance **off** after a stop has its own type, `RestartDelay` (category `DeviceProtection`): set `context.switching` with `hold: KeptOff`, `minOffSeconds` and `untilIso` (when it may restart), and `powerW` to the power waiting for it — e.g. "Short pause after switching off — charging resumes at 13:15 so the car isn't switched on and off too often."
4177
+
3996
4178
  ## Dynamic Grid Fees & Tariff Bonuses
3997
4179
 
3998
4180
  An electricity price is rarely one number. It is the energy price, plus the grid operator's network
@@ -4560,6 +4742,28 @@ Notes:
4560
4742
  - V1 is unchanged and remains fully supported. V2 is an additive sibling, not a
4561
4743
  migration.
4562
4744
 
4745
+ #### Forecasting When a Heat Pump Runs
4746
+
4747
+ A heat pump integration can publish its own forecast of when the heat pump will run with
4748
+ `HeatpumpOperationForecastV1` (or `publishHeatpumpOperationForecast()` on `IntegrationEnergyApp`).
4749
+ It is a prediction, not a request for power — to offer power, send `ApplianceFlexibilityAnnouncementV2`.
4750
+
4751
+ Each entry covers one slot of `resolution`, starting at `timestampIso`. `outdoorTemperatureC` and
4752
+ `running` are always present; `flowTemperatureC`, `generatedHeatWh`, `consumptionWh` and
4753
+ `averagePowerW` are optional. Energy values are per slot, and `averagePowerW` is averaged over the
4754
+ whole slot. Each message replaces the previous forecast for the appliance.
4755
+
4756
+ ```typescript
4757
+ this.publishHeatpumpOperationForecast('heatpump-1', {
4758
+ resolution: ForecastResolutionEnum.OneHour,
4759
+ entries: [
4760
+ {timestampIso: '2026-10-06T06:00:00Z', outdoorTemperatureC: 4.5, running: true,
4761
+ flowTemperatureC: 38, generatedHeatWh: 5200, consumptionWh: 1600, averagePowerW: 1600},
4762
+ {timestampIso: '2026-10-06T07:00:00Z', outdoorTemperatureC: 5.0, running: false},
4763
+ ],
4764
+ });
4765
+ ```
4766
+
4563
4767
  #### Explaining Why a Command Was Issued
4564
4768
 
4565
4769
  Every data bus command can carry an `EnyoDataBusCommandReason`. Its `type`
@@ -41,6 +41,8 @@ var EnergyAppModelFeatureEnum;
41
41
  EnergyAppModelFeatureEnum["HeatpumpPowerModulation"] = "heatpump-power-modulation";
42
42
  /** The model supports the SG Ready interface for smart-grid signalling */
43
43
  EnergyAppModelFeatureEnum["HeatpumpSgReady"] = "heatpump-sg-ready";
44
+ /** The heat pump accepts measured room temperatures from an external sensor as control input */
45
+ EnergyAppModelFeatureEnum["HeatpumpRoomTemperatureInput"] = "heatpump-room-temperature-input";
44
46
  // Climate Control / Air Conditioning
45
47
  /** The target temperature setpoint can be controlled */
46
48
  EnergyAppModelFeatureEnum["ClimateTemperatureSetpoint"] = "climate-temperature-setpoint";
@@ -33,6 +33,8 @@ export declare enum EnergyAppModelFeatureEnum {
33
33
  HeatpumpPowerModulation = "heatpump-power-modulation",
34
34
  /** The model supports the SG Ready interface for smart-grid signalling */
35
35
  HeatpumpSgReady = "heatpump-sg-ready",
36
+ /** The heat pump accepts measured room temperatures from an external sensor as control input */
37
+ HeatpumpRoomTemperatureInput = "heatpump-room-temperature-input",
36
38
  /** The target temperature setpoint can be controlled */
37
39
  ClimateTemperatureSetpoint = "climate-temperature-setpoint",
38
40
  /** The operating mode (heat / cool / fan / off) can be controlled */
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.EnergyAppPackageFirmwareModeEnum = exports.EnergyAppPackageCompatibilityStatus = exports.EnergyAppPackageCategory = void 0;
3
+ exports.EnergyAppPackageFirmwareModeEnum = exports.EnergyAppPackageCompatibilityStatus = exports.EnergyAppPackageOptionsDeviceDetectionModbusModeEnum = exports.EnergyAppPackageCategory = void 0;
4
4
  exports.defineEnergyAppPackage = defineEnergyAppPackage;
5
5
  const version_js_1 = require("./version.cjs");
6
6
  var EnergyAppPackageCategory;
@@ -27,6 +27,34 @@ var EnergyAppPackageCategory;
27
27
  EnergyAppPackageCategory["Vehicle"] = "vehicle";
28
28
  EnergyAppPackageCategory["Other"] = "other";
29
29
  })(EnergyAppPackageCategory || (exports.EnergyAppPackageCategory = EnergyAppPackageCategory = {}));
30
+ /**
31
+ * How the Modbus device detection rules in
32
+ * {@link EnergyAppPackageOptionsDeviceDetection.modbus} are combined.
33
+ *
34
+ * Each name has two parts:
35
+ * - `Registers…`: how the entries (registers) of the `modbus` list combine.
36
+ * `Or` means one matching register is enough; `And` means every register has
37
+ * to match, all on the same unit id.
38
+ * - `MatchingValues…`: how the `matchingValues` within one register combine.
39
+ * `Or` means the register's value has to match one of them; `And` means it
40
+ * has to match all of them.
41
+ *
42
+ * Only read from the **first** entry of the `modbus` list and applies to the
43
+ * whole list; set it there and nowhere else. Defaults to
44
+ * {@link RegistersOr_MatchingValuesOr} when omitted, which is the behaviour of
45
+ * all existing rules.
46
+ */
47
+ var EnergyAppPackageOptionsDeviceDetectionModbusModeEnum;
48
+ (function (EnergyAppPackageOptionsDeviceDetectionModbusModeEnum) {
49
+ /** One register has to match, with one of its matching values. The default. */
50
+ EnergyAppPackageOptionsDeviceDetectionModbusModeEnum["RegistersOr_MatchingValuesOr"] = "RegistersOr_MatchingValuesOr";
51
+ /** One register has to match, with all of its matching values. */
52
+ EnergyAppPackageOptionsDeviceDetectionModbusModeEnum["RegistersOr_MatchingValuesAnd"] = "RegistersOr_MatchingValuesAnd";
53
+ /** Every register has to match, each with one of its matching values. */
54
+ EnergyAppPackageOptionsDeviceDetectionModbusModeEnum["RegistersAnd_MatchingValuesOr"] = "RegistersAnd_MatchingValuesOr";
55
+ /** Every register has to match, each with all of its matching values. */
56
+ EnergyAppPackageOptionsDeviceDetectionModbusModeEnum["RegistersAnd_MatchingValuesAnd"] = "RegistersAnd_MatchingValuesAnd";
57
+ })(EnergyAppPackageOptionsDeviceDetectionModbusModeEnum || (exports.EnergyAppPackageOptionsDeviceDetectionModbusModeEnum = EnergyAppPackageOptionsDeviceDetectionModbusModeEnum = {}));
30
58
  /**
31
59
  * Whether a declared compatibility entry means "this works" or "this is known
32
60
  * not to work".
@@ -40,8 +40,85 @@ export interface EnergyAppPackageOptionsDeviceDetectionHostname {
40
40
  operation: 'eq' | 'startsWith';
41
41
  matchingValue: string;
42
42
  }
43
+ /**
44
+ * Which Modbus register bank a device detection rule reads.
45
+ * - `'holding'`: holding registers, function code 3
46
+ * - `'input'`: input registers, function code 4
47
+ *
48
+ * The two banks are separate address spaces, so the same address may hold
49
+ * different data (or nothing) in each.
50
+ */
51
+ export type EnergyAppPackageOptionsDeviceDetectionModbusRegisterType = 'holding' | 'input';
52
+ /**
53
+ * How the Modbus device detection rules in
54
+ * {@link EnergyAppPackageOptionsDeviceDetection.modbus} are combined.
55
+ *
56
+ * Each name has two parts:
57
+ * - `Registers…`: how the entries (registers) of the `modbus` list combine.
58
+ * `Or` means one matching register is enough; `And` means every register has
59
+ * to match, all on the same unit id.
60
+ * - `MatchingValues…`: how the `matchingValues` within one register combine.
61
+ * `Or` means the register's value has to match one of them; `And` means it
62
+ * has to match all of them.
63
+ *
64
+ * Only read from the **first** entry of the `modbus` list and applies to the
65
+ * whole list; set it there and nowhere else. Defaults to
66
+ * {@link RegistersOr_MatchingValuesOr} when omitted, which is the behaviour of
67
+ * all existing rules.
68
+ */
69
+ export declare enum EnergyAppPackageOptionsDeviceDetectionModbusModeEnum {
70
+ /** One register has to match, with one of its matching values. The default. */
71
+ RegistersOr_MatchingValuesOr = "RegistersOr_MatchingValuesOr",
72
+ /** One register has to match, with all of its matching values. */
73
+ RegistersOr_MatchingValuesAnd = "RegistersOr_MatchingValuesAnd",
74
+ /** Every register has to match, each with one of its matching values. */
75
+ RegistersAnd_MatchingValuesOr = "RegistersAnd_MatchingValuesOr",
76
+ /** Every register has to match, each with all of its matching values. */
77
+ RegistersAnd_MatchingValuesAnd = "RegistersAnd_MatchingValuesAnd"
78
+ }
79
+ /**
80
+ * Optional device detection configuration for Modbus TCP register matching:
81
+ * read a register range and compare its decoded value against the matching
82
+ * values.
83
+ *
84
+ * How several entries and several matching values combine is set by
85
+ * {@link mode} on the first entry of the list.
86
+ *
87
+ * @example
88
+ * // Vendor name in holding registers AND model id in an input register:
89
+ * modbus: [
90
+ * {
91
+ * mode: EnergyAppPackageOptionsDeviceDetectionModbusModeEnum.RegistersAnd_MatchingValuesOr,
92
+ * unitIds: [1],
93
+ * registerAddress: 40001,
94
+ * registerSize: 2,
95
+ * type: 'string',
96
+ * matchingValues: ['SMA'],
97
+ * },
98
+ * {
99
+ * unitIds: [1],
100
+ * registerType: 'input',
101
+ * registerAddress: 30053,
102
+ * registerSize: 2,
103
+ * type: 'UInt32BE',
104
+ * matchingValues: ['9401', '9402'],
105
+ * },
106
+ * ]
107
+ */
43
108
  export interface EnergyAppPackageOptionsDeviceDetectionModbus {
109
+ /**
110
+ * How all entries of the `modbus` list and their matching values combine.
111
+ * Only read from the first entry of the list — set it there and leave it
112
+ * out on the others. Defaults to
113
+ * {@link EnergyAppPackageOptionsDeviceDetectionModbusModeEnum.RegistersOr_MatchingValuesOr}.
114
+ */
115
+ mode?: EnergyAppPackageOptionsDeviceDetectionModbusModeEnum;
44
116
  unitIds: number[];
117
+ /**
118
+ * Which register bank to read. Defaults to `'holding'` when omitted,
119
+ * which is the behaviour of all existing rules.
120
+ */
121
+ registerType?: EnergyAppPackageOptionsDeviceDetectionModbusRegisterType;
45
122
  /** Register address, for example 30001 */
46
123
  registerAddress: number;
47
124
  /** Register size, for example 2 for 30001 - 30002 */
@@ -506,6 +506,23 @@ class EnergyApp {
506
506
  useCommandLog() {
507
507
  return this.energyAppSdk.useCommandLog();
508
508
  }
509
+ /**
510
+ * Gets the Cascade API for the site's meter cascade: a second meter behind
511
+ * the primary meter (e.g. for a heatpump on a dedicated tariff) with its
512
+ * own electricity tariff, prices and grid fee.
513
+ *
514
+ * Use it to find out whether a cascade is active and which appliances are
515
+ * behind it, and to read or supply the cascade's tariff, prices and grid
516
+ * fee — appliances behind an active cascade are billed on those instead of
517
+ * on {@link useElectricityTariff} and {@link useGridFee}.
518
+ * @returns The Cascade API instance
519
+ * @throws {EnergyAppPermissionNotGrantedError} If the `ElectricityTariff`,
520
+ * `GridFeeRegister` or `GridFeeUse` permission required by the
521
+ * called method is not granted.
522
+ */
523
+ useCascade() {
524
+ return this.energyAppSdk.useCascade();
525
+ }
509
526
  /**
510
527
  * Gets the current SDK version.
511
528
  * @returns The semantic version string of the SDK
@@ -50,6 +50,7 @@ import { EnergyAppDeviceTest } from "./packages/energy-app-device-test.cjs";
50
50
  import { EnergyAppEpexSpotPrice } from "./packages/energy-app-epex-spot-price.cjs";
51
51
  import { EnergyAppGridFee } from "./packages/energy-app-grid-fee.cjs";
52
52
  import { EnergyAppCommandLog } from "./packages/energy-app-command-log.cjs";
53
+ import { EnergyAppCascade } from "./packages/energy-app-cascade.cjs";
53
54
  import { UseFetchOptions } from "./types/enyo-fetch.cjs";
54
55
  /**
55
56
  * Concrete implementation of {@link EnyoEnergyAppSdk} that delegates every call
@@ -426,6 +427,21 @@ export declare class EnergyApp implements EnyoEnergyAppSdk {
426
427
  * permission is not granted.
427
428
  */
428
429
  useCommandLog(): EnergyAppCommandLog;
430
+ /**
431
+ * Gets the Cascade API for the site's meter cascade: a second meter behind
432
+ * the primary meter (e.g. for a heatpump on a dedicated tariff) with its
433
+ * own electricity tariff, prices and grid fee.
434
+ *
435
+ * Use it to find out whether a cascade is active and which appliances are
436
+ * behind it, and to read or supply the cascade's tariff, prices and grid
437
+ * fee — appliances behind an active cascade are billed on those instead of
438
+ * on {@link useElectricityTariff} and {@link useGridFee}.
439
+ * @returns The Cascade API instance
440
+ * @throws {EnergyAppPermissionNotGrantedError} If the `ElectricityTariff`,
441
+ * `GridFeeRegister` or `GridFeeUse` permission required by the
442
+ * called method is not granted.
443
+ */
444
+ useCascade(): EnergyAppCascade;
429
445
  /**
430
446
  * Gets the current SDK version.
431
447
  * @returns The semantic version string of the SDK
@@ -49,6 +49,7 @@ import { EnergyAppDeviceTest } from "./packages/energy-app-device-test.cjs";
49
49
  import { EnergyAppEpexSpotPrice } from "./packages/energy-app-epex-spot-price.cjs";
50
50
  import { EnergyAppGridFee } from "./packages/energy-app-grid-fee.cjs";
51
51
  import { EnergyAppCommandLog } from "./packages/energy-app-command-log.cjs";
52
+ import { EnergyAppCascade } from "./packages/energy-app-cascade.cjs";
52
53
  import { UseFetchOptions } from "./types/enyo-fetch.cjs";
53
54
  export declare enum EnergyAppStateEnum {
54
55
  Launching = "launching",
@@ -181,4 +182,6 @@ export interface EnyoEnergyAppSdk {
181
182
  useGridFee: () => EnergyAppGridFee;
182
183
  /** Get the Command Log API for recording which register, configuration key or message this app wrote to an appliance, and reading those commands back */
183
184
  useCommandLog: () => EnergyAppCommandLog;
185
+ /** Get the Cascade API for the meter behind the primary meter: whether it is active, which appliances are behind it, and its own electricity tariff, prices and grid fee */
186
+ useCascade: () => EnergyAppCascade;
184
187
  }
@@ -531,6 +531,78 @@ class ApplianceManager {
531
531
  await this.energyApp.useAppliances().save(updated, applianceId);
532
532
  await this.primeCacheFromSdk(applianceId);
533
533
  }
534
+ /**
535
+ * Patches a single heating circuit of a heatpump — e.g. to publish a changed
536
+ * heating curve or time program — without resending the whole
537
+ * `heatingCircuits` array.
538
+ *
539
+ * {@link updateAppliance} merges `heatpump` metadata only one level deep,
540
+ * so passing `heatingCircuits` there replaces every circuit. This helper
541
+ * reads the stored array, shallow-merges `patch` into the circuit with the
542
+ * given `index` (appending a new circuit when none exists), keeps the
543
+ * array sorted by index, and saves. Other circuits are left untouched.
544
+ *
545
+ * Does nothing when the appliance does not exist.
546
+ *
547
+ * @param applianceId The ID of the heatpump appliance
548
+ * @param index The `index` of the heating circuit to patch
549
+ * @param patch The circuit fields to set; fields not given keep their stored value
550
+ * @throws {ApplianceManagerDisposedError} when called after {@link dispose}
551
+ */
552
+ async updateHeatpumpHeatingCircuit(applianceId, index, patch) {
553
+ this.throwIfDisposed();
554
+ const appliance = await this.energyApp.useAppliances().getById(applianceId);
555
+ if (!appliance)
556
+ return;
557
+ const circuits = [...(appliance.heatpump?.heatingCircuits ?? [])];
558
+ const position = circuits.findIndex(c => c.index === index);
559
+ if (position >= 0) {
560
+ circuits[position] = { ...circuits[position], ...patch, index };
561
+ }
562
+ else {
563
+ circuits.push({ ...patch, index });
564
+ circuits.sort((a, b) => a.index - b.index);
565
+ }
566
+ await this.energyApp.useAppliances().save(this.mergeApplianceData(appliance, { heatpump: { heatingCircuits: circuits } }), applianceId);
567
+ await this.primeCacheFromSdk(applianceId);
568
+ }
569
+ /**
570
+ * Patches a single domestic-hot-water zone of a heatpump — e.g. to publish
571
+ * a changed time program — without resending the whole `domesticHotWater`
572
+ * array. Works like {@link updateHeatpumpHeatingCircuit}.
573
+ *
574
+ * Appending a zone that does not exist yet requires `targetTemperatureC`
575
+ * in `patch`, because a DHW zone cannot be stored without it.
576
+ *
577
+ * Does nothing when the appliance does not exist.
578
+ *
579
+ * @param applianceId The ID of the heatpump appliance
580
+ * @param index The `index` of the DHW zone to patch
581
+ * @param patch The zone fields to set; fields not given keep their stored value
582
+ * @throws {ApplianceManagerDisposedError} when called after {@link dispose}
583
+ * @throws {Error} when the zone does not exist and `patch` lacks `targetTemperatureC`
584
+ */
585
+ async updateHeatpumpDomesticHotWater(applianceId, index, patch) {
586
+ this.throwIfDisposed();
587
+ const appliance = await this.energyApp.useAppliances().getById(applianceId);
588
+ if (!appliance)
589
+ return;
590
+ const zones = [...(appliance.heatpump?.domesticHotWater ?? [])];
591
+ const position = zones.findIndex(z => z.index === index);
592
+ if (position >= 0) {
593
+ zones[position] = { ...zones[position], ...patch, index };
594
+ }
595
+ else {
596
+ const { targetTemperatureC } = patch;
597
+ if (targetTemperatureC === undefined) {
598
+ throw new Error(`Cannot add domestic hot water zone ${index} to appliance ${applianceId} without targetTemperatureC`);
599
+ }
600
+ zones.push({ ...patch, index, targetTemperatureC });
601
+ zones.sort((a, b) => a.index - b.index);
602
+ }
603
+ await this.energyApp.useAppliances().save(this.mergeApplianceData(appliance, { heatpump: { domesticHotWater: zones } }), applianceId);
604
+ await this.primeCacheFromSdk(applianceId);
605
+ }
534
606
  /**
535
607
  * Refetches an appliance from the SDK and reflects the result in the
536
608
  * cache. Used by mutating methods to keep cache reads consistent with
@@ -3,7 +3,7 @@ import type { EnyoNetworkDevice } from "../../types/enyo-network-device.cjs";
3
3
  import { EnyoAppliance, EnyoApplianceAvailableFeaturesEnum, EnyoApplianceConnectionType, EnyoApplianceMetadata, EnyoApplianceName, EnyoApplianceStateEnum, EnyoApplianceTopology, EnyoApplianceTypeEnum } from "../../types/enyo-appliance.cjs";
4
4
  import type { EnyoApplianceCreatedFilter } from "../../packages/energy-app-appliance.cjs";
5
5
  import type { EnyoChargerApplianceMetadata } from "../../types/enyo-charger-appliance.cjs";
6
- import type { EnyoHeatpumpApplianceMetadata } from "../../types/enyo-heatpump-appliance.cjs";
6
+ import type { EnyoHeatpumpApplianceDomesticHotWater, EnyoHeatpumpApplianceHeatingCircuit, EnyoHeatpumpApplianceMetadata } from "../../types/enyo-heatpump-appliance.cjs";
7
7
  import type { EnyoBatteryApplianceMetadata } from "../../types/enyo-battery-appliance.cjs";
8
8
  import type { EnyoInverterApplianceMetadata } from "../../types/enyo-inverter-appliance.cjs";
9
9
  import type { EnyoMeterAppliance } from "../../types/enyo-meter-appliance.cjs";
@@ -329,6 +329,42 @@ export declare class ApplianceManager {
329
329
  * @throws {ApplianceManagerDisposedError} when called after {@link dispose}
330
330
  */
331
331
  updateAppliance(applianceId: string, attributes: Partial<PartialEnyoAppliance>): Promise<void>;
332
+ /**
333
+ * Patches a single heating circuit of a heatpump — e.g. to publish a changed
334
+ * heating curve or time program — without resending the whole
335
+ * `heatingCircuits` array.
336
+ *
337
+ * {@link updateAppliance} merges `heatpump` metadata only one level deep,
338
+ * so passing `heatingCircuits` there replaces every circuit. This helper
339
+ * reads the stored array, shallow-merges `patch` into the circuit with the
340
+ * given `index` (appending a new circuit when none exists), keeps the
341
+ * array sorted by index, and saves. Other circuits are left untouched.
342
+ *
343
+ * Does nothing when the appliance does not exist.
344
+ *
345
+ * @param applianceId The ID of the heatpump appliance
346
+ * @param index The `index` of the heating circuit to patch
347
+ * @param patch The circuit fields to set; fields not given keep their stored value
348
+ * @throws {ApplianceManagerDisposedError} when called after {@link dispose}
349
+ */
350
+ updateHeatpumpHeatingCircuit(applianceId: string, index: number, patch: Partial<Omit<EnyoHeatpumpApplianceHeatingCircuit, 'index'>>): Promise<void>;
351
+ /**
352
+ * Patches a single domestic-hot-water zone of a heatpump — e.g. to publish
353
+ * a changed time program — without resending the whole `domesticHotWater`
354
+ * array. Works like {@link updateHeatpumpHeatingCircuit}.
355
+ *
356
+ * Appending a zone that does not exist yet requires `targetTemperatureC`
357
+ * in `patch`, because a DHW zone cannot be stored without it.
358
+ *
359
+ * Does nothing when the appliance does not exist.
360
+ *
361
+ * @param applianceId The ID of the heatpump appliance
362
+ * @param index The `index` of the DHW zone to patch
363
+ * @param patch The zone fields to set; fields not given keep their stored value
364
+ * @throws {ApplianceManagerDisposedError} when called after {@link dispose}
365
+ * @throws {Error} when the zone does not exist and `patch` lacks `targetTemperatureC`
366
+ */
367
+ updateHeatpumpDomesticHotWater(applianceId: string, index: number, patch: Partial<Omit<EnyoHeatpumpApplianceDomesticHotWater, 'index'>>): Promise<void>;
332
368
  /**
333
369
  * Refetches an appliance from the SDK and reflects the result in the
334
370
  * cache. Used by mutating methods to keep cache reads consistent with