@enyo-energy/energy-app-sdk 1.12.0 → 1.14.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 (64) hide show
  1. package/README.md +134 -5
  2. package/dist/cjs/implementations/automation/automation-validators.cjs +143 -10
  3. package/dist/cjs/implementations/automation/automation-validators.d.cts +15 -3
  4. package/dist/cjs/implementations/onboarding-v2/define-onboarding-guide-v2.cjs +95 -48
  5. package/dist/cjs/implementations/onboarding-v2/define-onboarding-guide-v2.d.cts +94 -41
  6. package/dist/cjs/implementations/onboarding-v2/onboarding-v2-provider-validators.cjs +12 -50
  7. package/dist/cjs/implementations/onboarding-v2/onboarding-v2-provider-validators.d.cts +10 -7
  8. package/dist/cjs/implementations/onboarding-v2/onboarding-v2-validators.cjs +188 -41
  9. package/dist/cjs/index.cjs +3 -0
  10. package/dist/cjs/index.d.cts +3 -0
  11. package/dist/cjs/packages/energy-app-automation.d.cts +44 -0
  12. package/dist/cjs/packages/energy-app-onboarding-v2.d.cts +48 -7
  13. package/dist/cjs/types/enyo-authentication.cjs +27 -1
  14. package/dist/cjs/types/enyo-authentication.d.cts +86 -7
  15. package/dist/cjs/types/enyo-automation.cjs +40 -0
  16. package/dist/cjs/types/enyo-automation.d.cts +252 -7
  17. package/dist/cjs/types/enyo-data-bus-value.cjs +32 -3
  18. package/dist/cjs/types/enyo-data-bus-value.d.cts +236 -4
  19. package/dist/cjs/types/enyo-eebus.cjs +47 -1
  20. package/dist/cjs/types/enyo-eebus.d.cts +57 -0
  21. package/dist/cjs/types/enyo-energy-manager.cjs +73 -0
  22. package/dist/cjs/types/enyo-energy-manager.d.cts +70 -1
  23. package/dist/cjs/types/enyo-heatpump-appliance.cjs +35 -1
  24. package/dist/cjs/types/enyo-heatpump-appliance.d.cts +147 -0
  25. package/dist/cjs/types/enyo-onboarding-v2-device-select.cjs +2 -2
  26. package/dist/cjs/types/enyo-onboarding-v2-device-select.d.cts +31 -6
  27. package/dist/cjs/types/enyo-onboarding-v2-eebus-device-select.cjs +29 -0
  28. package/dist/cjs/types/enyo-onboarding-v2-eebus-device-select.d.cts +174 -0
  29. package/dist/cjs/types/enyo-onboarding-v2.cjs +36 -64
  30. package/dist/cjs/types/enyo-onboarding-v2.d.cts +297 -94
  31. package/dist/cjs/version.cjs +1 -1
  32. package/dist/cjs/version.d.cts +1 -1
  33. package/dist/implementations/automation/automation-validators.d.ts +15 -3
  34. package/dist/implementations/automation/automation-validators.js +142 -9
  35. package/dist/implementations/onboarding-v2/define-onboarding-guide-v2.d.ts +94 -41
  36. package/dist/implementations/onboarding-v2/define-onboarding-guide-v2.js +95 -48
  37. package/dist/implementations/onboarding-v2/onboarding-v2-provider-validators.d.ts +10 -7
  38. package/dist/implementations/onboarding-v2/onboarding-v2-provider-validators.js +12 -50
  39. package/dist/implementations/onboarding-v2/onboarding-v2-validators.js +188 -41
  40. package/dist/index.d.ts +3 -0
  41. package/dist/index.js +3 -0
  42. package/dist/packages/energy-app-automation.d.ts +44 -0
  43. package/dist/packages/energy-app-onboarding-v2.d.ts +48 -7
  44. package/dist/types/enyo-authentication.d.ts +86 -7
  45. package/dist/types/enyo-authentication.js +26 -0
  46. package/dist/types/enyo-automation.d.ts +252 -7
  47. package/dist/types/enyo-automation.js +40 -0
  48. package/dist/types/enyo-data-bus-value.d.ts +236 -4
  49. package/dist/types/enyo-data-bus-value.js +32 -3
  50. package/dist/types/enyo-eebus.d.ts +57 -0
  51. package/dist/types/enyo-eebus.js +46 -0
  52. package/dist/types/enyo-energy-manager.d.ts +70 -1
  53. package/dist/types/enyo-energy-manager.js +73 -0
  54. package/dist/types/enyo-heatpump-appliance.d.ts +147 -0
  55. package/dist/types/enyo-heatpump-appliance.js +34 -0
  56. package/dist/types/enyo-onboarding-v2-device-select.d.ts +31 -6
  57. package/dist/types/enyo-onboarding-v2-device-select.js +2 -2
  58. package/dist/types/enyo-onboarding-v2-eebus-device-select.d.ts +174 -0
  59. package/dist/types/enyo-onboarding-v2-eebus-device-select.js +28 -0
  60. package/dist/types/enyo-onboarding-v2.d.ts +297 -94
  61. package/dist/types/enyo-onboarding-v2.js +36 -64
  62. package/dist/version.d.ts +1 -1
  63. package/dist/version.js +1 -1
  64. package/package.json +1 -1
package/README.md CHANGED
@@ -85,6 +85,7 @@ The official TypeScript SDK for building Energy Apps on the enyo platform. Creat
85
85
  - [Device Integration](#device-integration)
86
86
  - [Data Bus Messaging](#data-bus-messaging)
87
87
  - [Announcing Appliance Flexibility](#announcing-appliance-flexibility)
88
+ - [Explaining Why a Command Was Issued](#explaining-why-a-command-was-issued)
88
89
  - [Settings Management](#settings-management)
89
90
  - [Troubleshooting](#troubleshooting)
90
91
  - [External Libraries](#external-libraries)
@@ -1680,9 +1681,27 @@ await energyApp.useOnboardingV2().registerAdditionalSetupHandler(async (request)
1680
1681
 
1681
1682
  Every such block must declare a `failed` outcome — it absorbs a rejection, a timeout, a missing handler, and an outcome value matching no branch (guide and handler are linked only by strings). Secrets are never logged, never prefilled, and not kept in run state. See [ONBOARDING.md](./ONBOARDING.md#app-defined-setups-additional-setup).
1682
1683
 
1684
+ Guides pick the device on a screen of their own. `device-select` lists the network devices the run found; `eebus-device-select` lists the discovered EEBUS peers, filtered by the SHIP device type they announce (`EnyoEebusDeviceTypeEnum`). Both carry their own `headline`/`description`, and both **skip the screen entirely** when exactly one candidate matches the filter — the handler still runs and the run is still bound, with `autoSelected: true` on the request. Register one handler per kind; a network device and an EEBUS peer are different things and neither handler is a fallback for the other:
1685
+
1686
+ ```typescript
1687
+ const onboarding = energyApp.useOnboardingV2();
1688
+
1689
+ await onboarding.registerDeviceSelectHandler(async (request) => ({
1690
+ requestId: request.requestId,
1691
+ applianceIds: await adoptDevices(request.devices, request.applianceId),
1692
+ }));
1693
+
1694
+ await onboarding.registerEebusDeviceSelectHandler(async (request) => ({
1695
+ requestId: request.requestId, // request.peer.ski identifies the paired device
1696
+ applianceIds: await adoptEebusPeer(request.peer, request.applianceId),
1697
+ }));
1698
+ ```
1699
+
1700
+ On an `offline-reconnect` run the request carries the appliance that went offline — re-point it and answer with the same `applianceId` rather than creating a second one. See [ONBOARDING.md](./ONBOARDING.md#picking-the-device-device-select).
1701
+
1683
1702
  Your answer wins over the host's own resolution, so a wrong value is worse than none — the installer pastes it into a charger and it fails minutes later as an `ocpp-connect` timeout. `validateOnboardingV2DynamicResult()` catches the copy-target mistakes (whitespace, a non-absolute URL, a plaintext scheme).
1684
1703
 
1685
- Each guide must carry the `vendorId`, `modelIds` and `startVariant` it applies to that is how the host selects one for a run, and there is no publish step left to bind them. See [ONBOARDING.md](./ONBOARDING.md#serving-guides-the-host-pulls-the-app-never-publishes) for the full v2 model.
1704
+ Each guide declares the `startVariant` it applies to (`device-not-found`, `device-found-config`, `manual-setup`, `maintenance`, `offline-reconnect`). It must **not** declare `vendorId` or `modelIds`: enyo attaches those bindings when the guide is registered, an app-supplied value is overwritten, and `validateOnboardingGuideV2()` warns about it. See [ONBOARDING.md](./ONBOARDING.md#serving-guides-the-host-pulls-the-app-never-publishes) for the full v2 model.
1686
1705
 
1687
1706
  Not permission-gated.
1688
1707
 
@@ -2943,17 +2962,21 @@ An `EnyoAutomation` is `{ id, name, enabled, trigger, actions[] }`. The pieces:
2943
2962
 
2944
2963
  | Concept | Type | Values / fields |
2945
2964
  | --- | --- | --- |
2946
- | **Trigger** | `EnyoAutomationTriggerTypeEnum` | `PvSurplusThreshold` → `{ thresholdW }` (activate above, deactivate below) |
2947
- | **Actionsmart plug** | `EnyoAutomationActionTypeEnum.SmartPlugSwitch` | `{ applianceId, minDurationMinutes }` — `minDurationMinutes` is `5…360` in steps of `5` |
2965
+ | **Trigger — PV surplus above** | `EnyoAutomationTriggerTypeEnum.PvSurplusThreshold` | `{ thresholdW }` (activate above, deactivate below) |
2966
+ | **TriggerPV surplus below** | `EnyoAutomationTriggerTypeEnum.PvSurplusBelowThreshold` | `{ thresholdW }` — the inverse: active while surplus stays below, deactivates (turns the target **off**) as soon as `surplusW >= thresholdW` |
2967
+ | **Trigger — below price limit** | `EnyoAutomationTriggerTypeEnum.BelowPriceLimit` | `{ limitPerKwh, currency? }` — active while the current **full gross** price per kWh (incl. taxes, grid fees and all other components) is strictly below the limit; negative limits are allowed |
2968
+ | **Trigger — cheapest share of day** | `EnyoAutomationTriggerTypeEnum.CheapestShareOfDay` | `{ sharePercent }` — active during the cheapest `sharePercent` % of the day's price intervals (e.g. `25` for the cheapest 25 %); integer `1…100` |
2969
+ | **Trigger — schedule ("Zeitplan")** | `EnyoAutomationTriggerTypeEnum.Schedule` | `{ windows[], timezone? }` — each window is `{ startTimeOfDay, endTimeOfDay, daysOfWeek? }` in local `HH:mm` (`0` = Sunday … `6` = Saturday, omit for every day); windows may wrap past midnight and may overlap — the trigger is active while **any** window covers the current time |
2970
+ | **Action — smart plug** | `EnyoAutomationActionTypeEnum.SmartPlugSwitch` | `{ applianceId, minDurationMinutes }` — `minDurationMinutes` is `1…360`: whole minutes up to `5` (so a **1-minute** runtime is supported), multiples of `5` above that |
2948
2971
  | **Action — MQTT** | `EnyoAutomationActionTypeEnum.Mqtt` | `{ topic, payloadTemplate, updateChargingPvSurplus, publishOptions? }` |
2949
2972
  | **Scheduling** | `EnyoAutomationSchedulingModeEnum` | `Mandatory` (run exactly while active) or `Flexible` (Energy Manager may choose whether/when within the active window) |
2950
2973
  | **Target kind** | `EnyoAutomationTargetKindEnum` | `Load` (consumes power — counts in the energy balance) or `Signal` (control signal only) |
2951
2974
 
2952
- The **MQTT** `payloadTemplate` is JSON that may embed the placeholders in `EnyoAutomationMqttPlaceholderEnum` — `{{state}}` (`on`/`off`), `{{surplusW}}`, `{{timestampIso}}`, `{{automationId}}` — which the platform substitutes before publishing.
2975
+ The **MQTT** `payloadTemplate` is JSON that may embed the placeholders in `EnyoAutomationMqttPlaceholderEnum` — `{{state}}` (`on`/`off`), `{{surplusW}}`, `{{pricePerKwh}}`, `{{timestampIso}}`, `{{automationId}}` — which the platform substitutes before publishing.
2953
2976
 
2954
2977
  Two things travel over the **data bus** vs. the **API**:
2955
2978
 
2956
- - **Trigger state** is the data-bus message `AutomationTriggerV1` (`EnyoDataBusAutomationTriggerV1`): `{ automationId, data: { active, trigger } }`, where `trigger` is the per-type `EnyoAutomationTriggerData` (for PV surplus: `{ triggerType, surplusW, thresholdW }`).
2979
+ - **Trigger state** is the data-bus message `AutomationTriggerV1` (`EnyoDataBusAutomationTriggerV1`): `{ automationId, data: { active, trigger } }`, where `trigger` is the per-type `EnyoAutomationTriggerData` (PV surplus: `{ triggerType, surplusW, thresholdW }`; below-price-limit: `{ triggerType, pricePerKwh, limitPerKwh, currency }`; cheapest-share-of-day: `{ triggerType, pricePerKwh, sharePercent, thresholdPricePerKwh, currency }`; schedule: `{ triggerType, windowIndex?, windowStartIso?, windowEndIso? }`).
2957
2980
  - The **forecast** is a method — `publishAutomationForecast()` — not a data-bus message.
2958
2981
 
2959
2982
  ### ⚡ Guide: Energy Manager apps
@@ -3603,6 +3626,112 @@ Notes:
3603
3626
  grant can be read against the announcement it answers. It is advisory — the
3604
3627
  command's `powerW` remains the only limit.
3605
3628
 
3629
+ #### Explaining Why a Command Was Issued
3630
+
3631
+ Every data bus command can carry an `EnyoDataBusCommandReason`. Its `type`
3632
+ (`EnyoDataBusCommandReasonTypeEnum`) and `category` are **closed, coarse
3633
+ vocabularies** — they exist for filtering, iconography and analytics, not for
3634
+ narrating a decision. There is no "thermal cost" or "efficiency" member, and you
3635
+ must not synthesize one: pick the honest type and attach the numbers.
3636
+
3637
+ The prose for the end user lives in `translation` (per language). The numbers
3638
+ behind that prose live in the optional `context`
3639
+ (`EnyoDataBusCommandReasonContext`), so a consumer can verify, re-render or audit
3640
+ the explanation instead of taking it on trust:
3641
+
3642
+ | Section | Answers | Key fields |
3643
+ | --- | --- | --- |
3644
+ | `proactive` | Did we act on a forecast rather than a measurement? | `boolean` |
3645
+ | `forecast` | What did we see coming? | `outdoorTemperatureC`, `thresholdTemperatureC`, `windowStartIso`/`windowEndIso`, `triggerTimestampIso` |
3646
+ | `placement` | When does the block run, and by when must it be done? | `startIso`, `endIso`, `durationMinutes`, `deadlineIso` |
3647
+ | `efficiency` | Why this slot and not the cheaper-looking one? | `coefficientOfPerformance`, `effectiveCostPerKwhThermal`, `compared*` |
3648
+ | `thermalStorage` | What ends the block? | `target`, `energyPerKelvinKwh`, `overheatKelvin`, `plannedEnergyKwh`, `absorbableEnergyKwh` |
3649
+
3650
+ Worked example — a buffer tank charged ahead of a cold front. The command is
3651
+ `ScheduledOptimization` (category `Schedule`), which is the honest type for a
3652
+ block an optimizer placed in advance:
3653
+
3654
+ ```typescript
3655
+ import {
3656
+ EnyoCurrencyEnum,
3657
+ EnyoDataBusCommandReason,
3658
+ EnyoDataBusCommandReasonCategoryEnum,
3659
+ EnyoDataBusCommandReasonTypeEnum,
3660
+ EnyoFlexibilityTargetEnum,
3661
+ } from '@enyo-energy/energy-app-sdk';
3662
+
3663
+ const reason: EnyoDataBusCommandReason = {
3664
+ type: EnyoDataBusCommandReasonTypeEnum.ScheduledOptimization,
3665
+ category: EnyoDataBusCommandReasonCategoryEnum.Schedule,
3666
+ translation: [
3667
+ {
3668
+ language: 'en',
3669
+ value:
3670
+ 'Banking 2 hours of cheap heat into the buffer tank before tonight\'s cold front, ' +
3671
+ 'so the compressor can coast through the expensive hours.',
3672
+ },
3673
+ ],
3674
+ // Per kWh of *electricity* — the raw price of the chosen slot.
3675
+ electricityPricePerKwh: 0.30,
3676
+ currency: EnyoCurrencyEnum.EUR,
3677
+ context: {
3678
+ // We watch the weather, not the tank.
3679
+ proactive: true,
3680
+ forecast: {
3681
+ outdoorTemperatureC: 6.5,
3682
+ thresholdTemperatureC: 10,
3683
+ windowStartIso: '2026-01-14T12:00:00Z',
3684
+ windowEndIso: '2026-01-15T12:00:00Z',
3685
+ triggerTimestampIso: '2026-01-14T21:00:00Z',
3686
+ },
3687
+ // A 120-minute block, finished before that cold front arrives.
3688
+ placement: {
3689
+ startIso: '2026-01-14T12:00:00Z',
3690
+ endIso: '2026-01-14T14:00:00Z',
3691
+ durationMinutes: 120,
3692
+ deadlineIso: '2026-01-14T21:00:00Z',
3693
+ },
3694
+ // Placed by price ÷ COP, not price alone: a warm midday hour at 30 ct
3695
+ // delivers cheaper heat than a cold night hour at 20 ct.
3696
+ efficiency: {
3697
+ coefficientOfPerformance: 4.2,
3698
+ effectiveCostPerKwhThermal: 0.0714,
3699
+ comparedElectricityPricePerKwh: 0.20,
3700
+ comparedCoefficientOfPerformance: 2.4,
3701
+ comparedEffectiveCostPerKwhThermal: 0.0833,
3702
+ comparedStartIso: '2026-01-14T02:00:00Z',
3703
+ },
3704
+ // And it stops once the store is physically full: the pump tells us one
3705
+ // Kelvin of overheat absorbs 1.2 kWh, so 5 K of headroom is 6.1 kWh.
3706
+ thermalStorage: {
3707
+ target: EnyoFlexibilityTargetEnum.BufferTank,
3708
+ energyPerKelvinKwh: 1.2,
3709
+ overheatKelvin: 5,
3710
+ plannedEnergyKwh: 6.0,
3711
+ absorbableEnergyKwh: 6.1,
3712
+ },
3713
+ },
3714
+ };
3715
+ ```
3716
+
3717
+ Notes:
3718
+
3719
+ - **`context` is entirely optional and additive.** A reason that sets none of it
3720
+ behaves exactly as before; consumers must tolerate every section being absent.
3721
+ - **Costs are not all per kWh of electricity.** `electricityPricePerKwh` is per
3722
+ kWh drawn; `effectiveCostPerKwhThermal` is per kWh *delivered*, in the same
3723
+ `currency`. Mixing the two is what makes an efficiency-placed command look like
3724
+ a mistake.
3725
+ - **One rejected alternative, not a solver dump.** The `compared*` fields describe
3726
+ the single best slot that lost, because that is what explains the choice to a
3727
+ person.
3728
+ - **Nothing in a reason controls anything.** It explains a command; the command's
3729
+ own fields stay authoritative.
3730
+ - **`thermalStorage.target` reuses `EnyoFlexibilityTargetEnum`**, the same
3731
+ vocabulary as flexibility announcements and the `targets` breakdown on
3732
+ available-power commands — so a grant and its justification name the same
3733
+ physical sink without a mapping table.
3734
+
3606
3735
  ### Settings Management
3607
3736
 
3608
3737
  Dynamic configuration with user interface:
@@ -1,13 +1,15 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.AUTOMATION_DURATION_STEP_MINUTES = exports.AUTOMATION_MAX_DURATION_MINUTES = exports.AUTOMATION_MIN_DURATION_MINUTES = exports.AutomationValidationError = void 0;
3
+ exports.AUTOMATION_MAX_CHEAPEST_SHARE_PERCENT = exports.AUTOMATION_MIN_CHEAPEST_SHARE_PERCENT = exports.AUTOMATION_DURATION_STEP_MINUTES = exports.AUTOMATION_MAX_DURATION_MINUTES = exports.AUTOMATION_MIN_DURATION_MINUTES = exports.AutomationValidationError = void 0;
4
4
  exports.validateAutomation = validateAutomation;
5
5
  exports.validateTrigger = validateTrigger;
6
6
  exports.validateAction = validateAction;
7
7
  exports.validateAutomationForecast = validateAutomationForecast;
8
8
  exports.validateAutomationTriggerData = validateAutomationTriggerData;
9
9
  const enyo_automation_js_1 = require("../../types/enyo-automation.cjs");
10
+ const enyo_currency_js_1 = require("../../types/enyo-currency.cjs");
10
11
  const enyo_mqtt_js_1 = require("../../types/enyo-mqtt.cjs");
12
+ const price_schedule_resolver_js_1 = require("../pricing/price-schedule-resolver.cjs");
11
13
  /**
12
14
  * Thrown when an automation, action or automation forecast violates one of the
13
15
  * invariants enforced by this module. The message names the offending field /
@@ -20,12 +22,24 @@ class AutomationValidationError extends Error {
20
22
  }
21
23
  }
22
24
  exports.AutomationValidationError = AutomationValidationError;
23
- /** Minimum smart-plug on-duration, in minutes. */
24
- exports.AUTOMATION_MIN_DURATION_MINUTES = 5;
25
+ /**
26
+ * Minimum smart-plug on-duration, in minutes. Short runtimes below
27
+ * {@link AUTOMATION_DURATION_STEP_MINUTES} are allowed at a one-minute
28
+ * granularity, so a one-minute runtime is valid.
29
+ */
30
+ exports.AUTOMATION_MIN_DURATION_MINUTES = 1;
25
31
  /** Maximum smart-plug on-duration, in minutes (6 hours). */
26
32
  exports.AUTOMATION_MAX_DURATION_MINUTES = 360;
27
- /** Step size the smart-plug on-duration must be a multiple of, in minutes. */
33
+ /**
34
+ * Step size the smart-plug on-duration must be a multiple of, in minutes, once
35
+ * it exceeds {@link AUTOMATION_DURATION_STEP_MINUTES}. Durations at or below
36
+ * this value are free to use any whole minute (1, 2, 3, 4, 5).
37
+ */
28
38
  exports.AUTOMATION_DURATION_STEP_MINUTES = 5;
39
+ /** Minimum share of the day, in percent, a cheapest-share trigger may select. */
40
+ exports.AUTOMATION_MIN_CHEAPEST_SHARE_PERCENT = 1;
41
+ /** Maximum share of the day, in percent, a cheapest-share trigger may select. */
42
+ exports.AUTOMATION_MAX_CHEAPEST_SHARE_PERCENT = 100;
29
43
  /** Duration in seconds of each {@link EnyoForecastResolution} value. */
30
44
  const FORECAST_RESOLUTION_SECONDS = {
31
45
  '10s': 10,
@@ -87,6 +101,31 @@ function validateTrigger(trigger) {
87
101
  throw new AutomationValidationError('PvSurplusThreshold trigger.thresholdW must be a finite number >= 0.');
88
102
  }
89
103
  break;
104
+ case enyo_automation_js_1.EnyoAutomationTriggerTypeEnum.PvSurplusBelowThreshold:
105
+ if (typeof trigger.thresholdW !== 'number' || !Number.isFinite(trigger.thresholdW) || trigger.thresholdW < 0) {
106
+ throw new AutomationValidationError('PvSurplusBelowThreshold trigger.thresholdW must be a finite number >= 0.');
107
+ }
108
+ break;
109
+ case enyo_automation_js_1.EnyoAutomationTriggerTypeEnum.BelowPriceLimit:
110
+ // Negative limits are valid: dynamic prices can turn negative.
111
+ if (typeof trigger.limitPerKwh !== 'number' || !Number.isFinite(trigger.limitPerKwh)) {
112
+ throw new AutomationValidationError('BelowPriceLimit trigger.limitPerKwh must be a finite number.');
113
+ }
114
+ if (trigger.currency !== undefined) {
115
+ requireEnumMember(trigger.currency, enyo_currency_js_1.EnyoCurrencyEnum, 'BelowPriceLimit trigger.currency');
116
+ }
117
+ break;
118
+ case enyo_automation_js_1.EnyoAutomationTriggerTypeEnum.CheapestShareOfDay:
119
+ if (typeof trigger.sharePercent !== 'number' ||
120
+ !Number.isInteger(trigger.sharePercent) ||
121
+ trigger.sharePercent < exports.AUTOMATION_MIN_CHEAPEST_SHARE_PERCENT ||
122
+ trigger.sharePercent > exports.AUTOMATION_MAX_CHEAPEST_SHARE_PERCENT) {
123
+ throw new AutomationValidationError(`CheapestShareOfDay trigger.sharePercent must be an integer between ${exports.AUTOMATION_MIN_CHEAPEST_SHARE_PERCENT} and ${exports.AUTOMATION_MAX_CHEAPEST_SHARE_PERCENT}.`);
124
+ }
125
+ break;
126
+ case enyo_automation_js_1.EnyoAutomationTriggerTypeEnum.Schedule:
127
+ validateScheduleTrigger(trigger);
128
+ break;
90
129
  default:
91
130
  throw new AutomationValidationError(`Automation.trigger.type is invalid: ${trigger.type}.`);
92
131
  }
@@ -173,13 +212,90 @@ function validateAutomationTriggerData(trigger) {
173
212
  }
174
213
  switch (trigger.triggerType) {
175
214
  case enyo_automation_js_1.EnyoAutomationTriggerTypeEnum.PvSurplusThreshold:
215
+ case enyo_automation_js_1.EnyoAutomationTriggerTypeEnum.PvSurplusBelowThreshold:
176
216
  requireFiniteNonNegative(trigger.surplusW, 'AutomationTriggerData.surplusW');
177
217
  requireFiniteNonNegative(trigger.thresholdW, 'AutomationTriggerData.thresholdW');
178
218
  break;
219
+ case enyo_automation_js_1.EnyoAutomationTriggerTypeEnum.BelowPriceLimit:
220
+ requireFinite(trigger.pricePerKwh, 'AutomationTriggerData.pricePerKwh');
221
+ requireFinite(trigger.limitPerKwh, 'AutomationTriggerData.limitPerKwh');
222
+ requireEnumMember(trigger.currency, enyo_currency_js_1.EnyoCurrencyEnum, 'AutomationTriggerData.currency');
223
+ break;
224
+ case enyo_automation_js_1.EnyoAutomationTriggerTypeEnum.CheapestShareOfDay:
225
+ requireFinite(trigger.pricePerKwh, 'AutomationTriggerData.pricePerKwh');
226
+ requireFinite(trigger.thresholdPricePerKwh, 'AutomationTriggerData.thresholdPricePerKwh');
227
+ if (typeof trigger.sharePercent !== 'number' ||
228
+ !Number.isInteger(trigger.sharePercent) ||
229
+ trigger.sharePercent < exports.AUTOMATION_MIN_CHEAPEST_SHARE_PERCENT ||
230
+ trigger.sharePercent > exports.AUTOMATION_MAX_CHEAPEST_SHARE_PERCENT) {
231
+ throw new AutomationValidationError(`AutomationTriggerData.sharePercent must be an integer between ${exports.AUTOMATION_MIN_CHEAPEST_SHARE_PERCENT} and ${exports.AUTOMATION_MAX_CHEAPEST_SHARE_PERCENT}.`);
232
+ }
233
+ requireEnumMember(trigger.currency, enyo_currency_js_1.EnyoCurrencyEnum, 'AutomationTriggerData.currency');
234
+ break;
235
+ case enyo_automation_js_1.EnyoAutomationTriggerTypeEnum.Schedule:
236
+ if (trigger.windowIndex !== undefined && (!Number.isInteger(trigger.windowIndex) || trigger.windowIndex < 0)) {
237
+ throw new AutomationValidationError('AutomationTriggerData.windowIndex must be an integer >= 0.');
238
+ }
239
+ requireOptionalIsoTimestamp(trigger.windowStartIso, 'AutomationTriggerData.windowStartIso');
240
+ requireOptionalIsoTimestamp(trigger.windowEndIso, 'AutomationTriggerData.windowEndIso');
241
+ break;
179
242
  default:
180
243
  throw new AutomationValidationError(`AutomationTriggerData.triggerType is invalid: ${trigger.triggerType}.`);
181
244
  }
182
245
  }
246
+ /**
247
+ * Validates the windows of a {@link EnyoAutomationTriggerTypeEnum.Schedule}
248
+ * ("Zeitplan") trigger: at least one window, well-formed `HH:mm` times that do
249
+ * not start and end at the same minute, weekday numbers in range, and a valid
250
+ * IANA timezone when given. Windows are a union and may overlap, so no overlap
251
+ * check is performed.
252
+ */
253
+ function validateScheduleTrigger(trigger) {
254
+ if (!Array.isArray(trigger.windows) || trigger.windows.length === 0) {
255
+ throw new AutomationValidationError('Schedule trigger.windows must contain at least one window.');
256
+ }
257
+ trigger.windows.forEach((window, index) => {
258
+ const label = `Schedule trigger.windows[${index}]`;
259
+ if (!window || typeof window !== 'object') {
260
+ throw new AutomationValidationError(`${label} must be an object.`);
261
+ }
262
+ const start = (0, price_schedule_resolver_js_1.parseTimeOfDay)(window.startTimeOfDay);
263
+ if (start === null) {
264
+ throw new AutomationValidationError(`${label}.startTimeOfDay must be a 24-hour 'HH:mm' time, got '${window.startTimeOfDay}'.`);
265
+ }
266
+ const end = (0, price_schedule_resolver_js_1.parseTimeOfDay)(window.endTimeOfDay);
267
+ if (end === null) {
268
+ throw new AutomationValidationError(`${label}.endTimeOfDay must be a 24-hour 'HH:mm' time, got '${window.endTimeOfDay}'.`);
269
+ }
270
+ if (start === end) {
271
+ throw new AutomationValidationError(`${label} must not start and end at the same time of day.`);
272
+ }
273
+ if (window.daysOfWeek !== undefined) {
274
+ if (!Array.isArray(window.daysOfWeek) || window.daysOfWeek.length === 0) {
275
+ throw new AutomationValidationError(`${label}.daysOfWeek must not be empty — omit it for 'every day'.`);
276
+ }
277
+ const seenDays = new Set();
278
+ for (const day of window.daysOfWeek) {
279
+ if (!Number.isInteger(day) || day < 0 || day > 6) {
280
+ throw new AutomationValidationError(`${label}.daysOfWeek must contain integers 0 (Sunday) to 6 (Saturday), got ${day}.`);
281
+ }
282
+ if (seenDays.has(day)) {
283
+ throw new AutomationValidationError(`${label}.daysOfWeek contains the duplicate day ${day}.`);
284
+ }
285
+ seenDays.add(day);
286
+ }
287
+ }
288
+ });
289
+ if (trigger.timezone !== undefined) {
290
+ requireNonEmptyString(trigger.timezone, 'Schedule trigger.timezone');
291
+ try {
292
+ new Intl.DateTimeFormat('en-US', { timeZone: trigger.timezone });
293
+ }
294
+ catch {
295
+ throw new AutomationValidationError(`Schedule trigger.timezone must be a valid IANA time zone identifier, got '${trigger.timezone}'.`);
296
+ }
297
+ }
298
+ }
183
299
  function validateMqttAction(action, label) {
184
300
  requireNonEmptyString(action.topic, `${label}.topic`);
185
301
  if (typeof action.updateChargingPvSurplus !== 'boolean') {
@@ -203,12 +319,16 @@ function validateSmartPlugSwitchAction(action, label, knownSmartPlugApplianceIds
203
319
  throw new AutomationValidationError(`${label}.applianceId does not reference a smart-plug appliance that supports the smart-plug-switch action: ${action.applianceId}.`);
204
320
  }
205
321
  const { minDurationMinutes } = action;
206
- if (typeof minDurationMinutes !== 'number' ||
207
- !Number.isInteger(minDurationMinutes) ||
208
- minDurationMinutes < exports.AUTOMATION_MIN_DURATION_MINUTES ||
209
- minDurationMinutes > exports.AUTOMATION_MAX_DURATION_MINUTES ||
210
- minDurationMinutes % exports.AUTOMATION_DURATION_STEP_MINUTES !== 0) {
211
- throw new AutomationValidationError(`${label}.minDurationMinutes must be an integer between ${exports.AUTOMATION_MIN_DURATION_MINUTES} and ${exports.AUTOMATION_MAX_DURATION_MINUTES} in steps of ${exports.AUTOMATION_DURATION_STEP_MINUTES}.`);
322
+ // Short runtimes (1-5 minutes) are allowed at whole-minute granularity so a
323
+ // one-minute pulse can be configured; anything longer keeps the 5-minute step.
324
+ const isWholeMinutesInRange = typeof minDurationMinutes === 'number' &&
325
+ Number.isInteger(minDurationMinutes) &&
326
+ minDurationMinutes >= exports.AUTOMATION_MIN_DURATION_MINUTES &&
327
+ minDurationMinutes <= exports.AUTOMATION_MAX_DURATION_MINUTES;
328
+ const matchesStep = minDurationMinutes <= exports.AUTOMATION_DURATION_STEP_MINUTES ||
329
+ minDurationMinutes % exports.AUTOMATION_DURATION_STEP_MINUTES === 0;
330
+ if (!isWholeMinutesInRange || !matchesStep) {
331
+ throw new AutomationValidationError(`${label}.minDurationMinutes must be an integer between ${exports.AUTOMATION_MIN_DURATION_MINUTES} and ${exports.AUTOMATION_MAX_DURATION_MINUTES}; values above ${exports.AUTOMATION_DURATION_STEP_MINUTES} must be a multiple of ${exports.AUTOMATION_DURATION_STEP_MINUTES}.`);
212
332
  }
213
333
  }
214
334
  /**
@@ -233,6 +353,19 @@ function validatePayloadTemplate(template, label) {
233
353
  throw new AutomationValidationError(`${label} is not valid JSON once placeholders are substituted.`);
234
354
  }
235
355
  }
356
+ function requireOptionalIsoTimestamp(value, label) {
357
+ if (value === undefined) {
358
+ return;
359
+ }
360
+ if (typeof value !== 'string' || Number.isNaN(Date.parse(value))) {
361
+ throw new AutomationValidationError(`${label} must be a valid ISO 8601 timestamp: ${String(value)}.`);
362
+ }
363
+ }
364
+ function requireFinite(value, label) {
365
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
366
+ throw new AutomationValidationError(`${label} must be a finite number.`);
367
+ }
368
+ }
236
369
  function requireFiniteNonNegative(value, label) {
237
370
  if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
238
371
  throw new AutomationValidationError(`${label} must be a finite number >= 0.`);
@@ -7,12 +7,24 @@ import { EnyoAutomation, EnyoAutomationAction, EnyoAutomationForecast, EnyoAutom
7
7
  export declare class AutomationValidationError extends Error {
8
8
  constructor(message: string);
9
9
  }
10
- /** Minimum smart-plug on-duration, in minutes. */
11
- export declare const AUTOMATION_MIN_DURATION_MINUTES = 5;
10
+ /**
11
+ * Minimum smart-plug on-duration, in minutes. Short runtimes below
12
+ * {@link AUTOMATION_DURATION_STEP_MINUTES} are allowed at a one-minute
13
+ * granularity, so a one-minute runtime is valid.
14
+ */
15
+ export declare const AUTOMATION_MIN_DURATION_MINUTES = 1;
12
16
  /** Maximum smart-plug on-duration, in minutes (6 hours). */
13
17
  export declare const AUTOMATION_MAX_DURATION_MINUTES = 360;
14
- /** Step size the smart-plug on-duration must be a multiple of, in minutes. */
18
+ /**
19
+ * Step size the smart-plug on-duration must be a multiple of, in minutes, once
20
+ * it exceeds {@link AUTOMATION_DURATION_STEP_MINUTES}. Durations at or below
21
+ * this value are free to use any whole minute (1, 2, 3, 4, 5).
22
+ */
15
23
  export declare const AUTOMATION_DURATION_STEP_MINUTES = 5;
24
+ /** Minimum share of the day, in percent, a cheapest-share trigger may select. */
25
+ export declare const AUTOMATION_MIN_CHEAPEST_SHARE_PERCENT = 1;
26
+ /** Maximum share of the day, in percent, a cheapest-share trigger may select. */
27
+ export declare const AUTOMATION_MAX_CHEAPEST_SHARE_PERCENT = 100;
16
28
  /**
17
29
  * Validates a complete {@link EnyoAutomation}. Throws on the first violation.
18
30
  *
@@ -243,69 +243,111 @@ exports.onboardingV2Block = {
243
243
  outcomes,
244
244
  }),
245
245
  /**
246
- * An EEBUS-pair action block: the installer picks one of the discovered
247
- * EEBUS peers and the host trusts its SKI.
246
+ * A device-select picker: its own screen listing the network devices the run
247
+ * has found, so the installer can say which one is being onboarded.
248
248
  *
249
- * A convenience wrapper over {@link onboardingV2Block.action} that pins the
250
- * action kind. The picker is drawn from what mDNS discovery found, so the
251
- * guide must have scanned keep
252
- * {@link EnyoOnboardingV2Guide.requiresNetworkScan} at its default or place
253
- * a {@link EnyoOnboardingV2ActionKind.NetworkScan} block ahead of this one.
249
+ * Use it whenever a scan can turn up more than one candidate. A
250
+ * {@link onboardingV2Block.networkScan} branches on found/not-found but binds
251
+ * nothing, so without this the run does not know *which* device it is working
252
+ * on — and {@link EnyoOnboardingV2DeviceSelection.Current} and
253
+ * {@link EnyoOnboardingV2DynamicKind.DeviceIp} have nothing to resolve
254
+ * against.
254
255
  *
255
- * Most EEBUS devices only announce themselves once pairing is enabled in
256
- * their own menu or portal, and many ask for a confirmation there while the
257
- * handshake runs, so put that instruction in a text/hint block on the
258
- * preceding step the app cannot do it for the installer.
256
+ * The block owns the step: give it one of its own, put the screen's wording
257
+ * in `headline`/`description` rather than in neighbouring blocks, and let it
258
+ * disappear when it has nothing to ask with `autoSelectSingleMatch` left at
259
+ * its default, a single matching device is bound without rendering anything.
259
260
  *
260
- * Outcome `value`s must be {@link EnyoOnboardingV2EebusPairOutcome} members;
261
- * route `not-found` to troubleshooting and `failure` to a step describing
262
- * the confirmation on the device. A retry must lead into a *second* pairing
263
- * step: a back-edge onto the same step reads as a loop and ends the run.
261
+ * The list is what discovery found, so the guide must have scanned: keep
262
+ * {@link EnyoOnboardingV2Guide.requiresNetworkScan} at its default, or place a
263
+ * {@link onboardingV2Block.networkScan} ahead of this one.
264
+ *
265
+ * Outcome `value`s must be {@link EnyoOnboardingV2DeviceSelectOutcome}
266
+ * members; route `not-found` to troubleshooting rather than to a step that
267
+ * assumes a device exists.
268
+ *
269
+ * Register an {@link EnyoOnboardingV2DeviceSelectHandler}
270
+ * ({@link EnergyAppOnboardingV2.registerDeviceSelectHandler}) to turn the pick
271
+ * into appliances — the host awaits it and binds the run to the ids it
272
+ * returns, whether or not the screen was shown. Without one the pick binds an
273
+ * address and nothing more.
264
274
  *
265
275
  * @param id - Stable block id, unique within the guide.
266
- * @param label - Translated trigger button text (de/en).
267
- * @param outcomes - The `paired` / `not-found` / `failure` results; each is a routing handle.
276
+ * @param options - Screen wording, optional `detectedAt` filter, skip
277
+ * behaviour, and the `selected` / `not-found` routing handles.
278
+ * @returns The device-select block.
279
+ *
280
+ * @example
281
+ * ```ts
282
+ * onboardingV2Block.deviceSelect('pick', {
283
+ * headline: t('Gerät auswählen', 'Select the device'),
284
+ * description: t(
285
+ * 'Vergleichen Sie die Seriennummer auf dem Typenschild.',
286
+ * 'Compare the serial number on the type plate.',
287
+ * ),
288
+ * detectedAt: [EnyoNetworkDeviceDetectedAtEnum.Modbus],
289
+ * outcomes: [
290
+ * {id: 'ok', value: EnyoOnboardingV2DeviceSelectOutcome.Selected, label: t('Ausgewählt', 'Selected')},
291
+ * {id: 'none', value: EnyoOnboardingV2DeviceSelectOutcome.NotFound, label: t('Nicht dabei', 'Not listed')},
292
+ * ],
293
+ * });
294
+ * ```
268
295
  */
269
- eebusPair: (id, label, outcomes) => ({
296
+ deviceSelect: (id, options) => ({
270
297
  id,
271
- type: enyo_onboarding_v2_js_1.EnyoOnboardingV2BlockType.Action,
272
- action: enyo_onboarding_v2_js_1.EnyoOnboardingV2ActionKind.EebusPair,
273
- label,
274
- outcomes,
298
+ type: enyo_onboarding_v2_js_1.EnyoOnboardingV2BlockType.DeviceSelect,
299
+ ...options,
275
300
  }),
276
301
  /**
277
- * A device-select block: the installer picks the device being onboarded from
278
- * everything the run has found.
302
+ * An EEBUS device-select picker: its own screen listing the discovered EEBUS
303
+ * peers, so the installer can pick the one to trust — the host pairs it and
304
+ * records its SKI.
279
305
  *
280
- * Use it whenever a scan can turn up more than one candidate. A
281
- * {@link block.networkScan} branches on found/not-found but binds nothing,
282
- * so without this the run does not know *which* device it is working on —
283
- * and {@link EnyoOnboardingV2DeviceSelection.Current} and
284
- * {@link EnyoOnboardingV2DynamicKind.DeviceIp} have nothing to resolve
285
- * against.
306
+ * Filter it. `deviceTypes` is what turns this from "here are the six EEBUS
307
+ * devices in the house" into "here is your heat pump", and with one match it
308
+ * skips the screen entirely instead of asking a question with one possible
309
+ * answer.
286
310
  *
287
- * The picker renders what discovery found, so the guide must have scanned:
288
- * keep {@link EnyoOnboardingV2Guide.requiresNetworkScan} at its default, or
289
- * place a {@link block.networkScan} ahead of this one.
311
+ * The picker is drawn from what mDNS discovery found, so the guide must have
312
+ * scanned — keep {@link EnyoOnboardingV2Guide.requiresNetworkScan} at its
313
+ * default or place a {@link onboardingV2Block.networkScan} ahead of this one.
290
314
  *
291
- * Outcome `value`s must be {@link EnyoOnboardingV2DeviceSelectOutcome}
292
- * members; route `not-found` to troubleshooting rather than to a step that
293
- * assumes a device exists.
315
+ * Most EEBUS devices only announce themselves once pairing is enabled in
316
+ * their own menu or portal, and many ask for a confirmation there while the
317
+ * handshake runs, so put that instruction in a text/hint block on the
318
+ * preceding step — the app cannot do it for the installer.
294
319
  *
295
- * Register an {@link EnyoOnboardingV2DeviceSelectHandler} to turn the pick
296
- * into appliances the host awaits it and binds the run to the ids it
297
- * returns. Without one the pick binds an address and nothing more.
320
+ * Outcome `value`s must be {@link EnyoOnboardingV2EebusPairOutcome} members;
321
+ * route `not-found` to troubleshooting and `failure` to a step describing the
322
+ * confirmation on the device. A retry must lead into a *second* picker step:
323
+ * a back-edge onto the same step reads as a loop and ends the run.
324
+ *
325
+ * Register an {@link EnyoOnboardingV2EebusDeviceSelectHandler}
326
+ * ({@link EnergyAppOnboardingV2.registerEebusDeviceSelectHandler}) to turn the
327
+ * paired peer into appliances.
298
328
  *
299
329
  * @param id - Stable block id, unique within the guide.
300
- * @param label - Translated trigger button text (de/en).
301
- * @param outcomes - The `selected` / `not-found` results; each is a routing handle.
330
+ * @param options - Screen wording, optional `deviceTypes` filter, skip
331
+ * behaviour, and the `paired` / `not-found` / `failure` routing handles.
332
+ * @returns The EEBUS device-select block.
333
+ *
334
+ * @example
335
+ * ```ts
336
+ * onboardingV2Block.eebusDeviceSelect('pair', {
337
+ * headline: t('Wärmepumpe auswählen', 'Select the heat pump'),
338
+ * deviceTypes: [EnyoEebusDeviceTypeEnum.HeatPumpAppliance],
339
+ * outcomes: [
340
+ * {id: 'ok', value: EnyoOnboardingV2EebusPairOutcome.Paired, label: t('Gekoppelt', 'Paired')},
341
+ * {id: 'none', value: EnyoOnboardingV2EebusPairOutcome.NotFound, label: t('Nichts gefunden', 'Nothing found')},
342
+ * {id: 'error', value: EnyoOnboardingV2EebusPairOutcome.Failure, label: t('Kopplung fehlgeschlagen', 'Pairing failed')},
343
+ * ],
344
+ * });
345
+ * ```
302
346
  */
303
- deviceSelect: (id, label, outcomes) => ({
347
+ eebusDeviceSelect: (id, options) => ({
304
348
  id,
305
- type: enyo_onboarding_v2_js_1.EnyoOnboardingV2BlockType.Action,
306
- action: enyo_onboarding_v2_js_1.EnyoOnboardingV2ActionKind.DeviceSelect,
307
- label,
308
- outcomes,
349
+ type: enyo_onboarding_v2_js_1.EnyoOnboardingV2BlockType.EebusDeviceSelect,
350
+ ...options,
309
351
  }),
310
352
  /**
311
353
  * An auth block: the installer signs into the energy app's own account
@@ -320,8 +362,12 @@ exports.onboardingV2Block = {
320
362
  * @param label - Translated sign-in button text (de/en).
321
363
  * @param outcome - The single success handle (`{id, label}`).
322
364
  * @param opts - Optional translated `help` naming the account that is needed,
323
- * and `requiresWebAuthentication` to force the login into a web browser
324
- * when the provider rejects a custom-scheme redirect such as `enyoapp://`.
365
+ * and `redirectUrlFilter` to constrain the enyo callback URL the host
366
+ * generates — `webOnly` when the provider rejects a custom-scheme redirect
367
+ * such as `enyoapp://`, `pattern` when its OAuth app was registered with a
368
+ * redirect URI that carries the request id as a path segment rather than a
369
+ * query parameter. `requiresWebAuthentication` is the deprecated spelling
370
+ * of `redirectUrlFilter.webOnly`.
325
371
  */
326
372
  auth: (id, label, outcome, opts) => ({
327
373
  id,
@@ -330,6 +376,7 @@ exports.onboardingV2Block = {
330
376
  outcome,
331
377
  help: opts?.help,
332
378
  requiresWebAuthentication: opts?.requiresWebAuthentication,
379
+ redirectUrlFilter: opts?.redirectUrlFilter,
333
380
  }),
334
381
  /**
335
382
  * A link block: a fixed URL the installer opens or copies.