@enyo-energy/energy-app-sdk 0.0.196 → 0.0.198

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 +91 -0
  2. package/dist/cjs/implementations/energy-manager-settings/energy-manager-settings-validators.cjs +273 -0
  3. package/dist/cjs/implementations/energy-manager-settings/energy-manager-settings-validators.d.cts +94 -0
  4. package/dist/cjs/implementations/onboarding-v2/define-onboarding-guide-v2.cjs +76 -1
  5. package/dist/cjs/implementations/onboarding-v2/define-onboarding-guide-v2.d.cts +66 -2
  6. package/dist/cjs/implementations/onboarding-v2/onboarding-v2-dynamic-validators.cjs +163 -0
  7. package/dist/cjs/implementations/onboarding-v2/onboarding-v2-dynamic-validators.d.cts +90 -0
  8. package/dist/cjs/implementations/onboarding-v2/onboarding-v2-validators.cjs +150 -0
  9. package/dist/cjs/index.cjs +5 -0
  10. package/dist/cjs/index.d.cts +5 -0
  11. package/dist/cjs/packages/energy-app-energy-manager.d.cts +136 -0
  12. package/dist/cjs/packages/energy-app-onboarding-v2.d.cts +163 -0
  13. package/dist/cjs/types/enyo-authentication.d.cts +19 -0
  14. package/dist/cjs/types/enyo-energy-manager-settings.cjs +182 -0
  15. package/dist/cjs/types/enyo-energy-manager-settings.d.cts +306 -0
  16. package/dist/cjs/types/enyo-heating-rod-appliance.d.cts +50 -0
  17. package/dist/cjs/types/enyo-heatpump-appliance.d.cts +40 -0
  18. package/dist/cjs/types/enyo-onboarding-v2-additional-setup.cjs +38 -0
  19. package/dist/cjs/types/enyo-onboarding-v2-additional-setup.d.cts +123 -0
  20. package/dist/cjs/types/enyo-onboarding-v2-dynamic.cjs +28 -0
  21. package/dist/cjs/types/enyo-onboarding-v2-dynamic.d.cts +102 -0
  22. package/dist/cjs/types/enyo-onboarding-v2.cjs +26 -1
  23. package/dist/cjs/types/enyo-onboarding-v2.d.cts +212 -4
  24. package/dist/cjs/version.cjs +1 -1
  25. package/dist/cjs/version.d.cts +1 -1
  26. package/dist/implementations/energy-manager-settings/energy-manager-settings-validators.d.ts +94 -0
  27. package/dist/implementations/energy-manager-settings/energy-manager-settings-validators.js +267 -0
  28. package/dist/implementations/onboarding-v2/define-onboarding-guide-v2.d.ts +66 -2
  29. package/dist/implementations/onboarding-v2/define-onboarding-guide-v2.js +75 -1
  30. package/dist/implementations/onboarding-v2/onboarding-v2-dynamic-validators.d.ts +90 -0
  31. package/dist/implementations/onboarding-v2/onboarding-v2-dynamic-validators.js +157 -0
  32. package/dist/implementations/onboarding-v2/onboarding-v2-validators.js +151 -1
  33. package/dist/index.d.ts +5 -0
  34. package/dist/index.js +5 -0
  35. package/dist/packages/energy-app-energy-manager.d.ts +136 -0
  36. package/dist/packages/energy-app-onboarding-v2.d.ts +163 -0
  37. package/dist/types/enyo-authentication.d.ts +19 -0
  38. package/dist/types/enyo-energy-manager-settings.d.ts +306 -0
  39. package/dist/types/enyo-energy-manager-settings.js +179 -0
  40. package/dist/types/enyo-heating-rod-appliance.d.ts +50 -0
  41. package/dist/types/enyo-heatpump-appliance.d.ts +40 -0
  42. package/dist/types/enyo-onboarding-v2-additional-setup.d.ts +123 -0
  43. package/dist/types/enyo-onboarding-v2-additional-setup.js +35 -0
  44. package/dist/types/enyo-onboarding-v2-dynamic.d.ts +102 -0
  45. package/dist/types/enyo-onboarding-v2-dynamic.js +27 -0
  46. package/dist/types/enyo-onboarding-v2.d.ts +212 -4
  47. package/dist/types/enyo-onboarding-v2.js +25 -0
  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
@@ -1127,6 +1127,65 @@ if (info) console.log(`Active manager: ${info.name} v${info.version}`);
1127
1127
 
1128
1128
  Requires the `EnergyManagerInfo` permission.
1129
1129
 
1130
+ ##### General settings
1131
+
1132
+ A closed, SDK-defined set of user-facing controls the active energy manager honours — the contract between the enyo cockpit and whichever manager is installed. Not to be confused with `useSettings()`, which is per-package, string-valued, and rendered on your own config screen.
1133
+
1134
+ | Setting | Type |
1135
+ |---|---|
1136
+ | `BatteryControl` | boolean |
1137
+ | `BatteryChargingMode` | `paced` \| `immediate` |
1138
+ | `BatteryChargeFromGrid` | boolean |
1139
+ | `BatteryDischargeWhileChargingWh` | number \| `null` (Wh allowance; `null`/unset = disabled) |
1140
+ | `BlockBatteryDischargeWhileEvCharging` | boolean |
1141
+ | `HeatpumpControl` | boolean |
1142
+ | `HeatingRodControl` | boolean |
1143
+ | `HeatingRodMode` | `pv-surplus-only` \| `boost` |
1144
+ | `ChargerControl` | boolean |
1145
+ | `DefaultChargeMode` | `EnyoChargeModeEnum` |
1146
+ | `PriceLimitCtPerKwh` | number, **ct/kWh** (`7` = 7 ct/kWh) |
1147
+ | `CostOptimizedTarget` | wall-clock `"07:30"` + IANA timezone |
1148
+
1149
+ An energy manager declares what it honours, next to `registerFeatures()`:
1150
+
1151
+ ```typescript
1152
+ em.registerSupportedSettings([
1153
+ EnergyManagerSettingEnum.BatteryControl,
1154
+ EnergyManagerSettingEnum.BatteryChargingMode,
1155
+ EnergyManagerSettingEnum.ChargerControl,
1156
+ EnergyManagerSettingEnum.DefaultChargeMode,
1157
+ ]);
1158
+
1159
+ // seed defaults without ever overriding the user
1160
+ await em.setEnergyManagerSettings(
1161
+ { batteryControl: true, batteryChargingMode: EnergyManagerBatteryChargingModeEnum.Paced },
1162
+ { onlyIfUnset: true },
1163
+ );
1164
+ ```
1165
+
1166
+ Anyone can read and react:
1167
+
1168
+ ```typescript
1169
+ const state = await em.getEnergyManagerSettings(); // null when no EM is configured
1170
+ if (state?.supported.includes(EnergyManagerSettingEnum.ChargerControl)
1171
+ && state.values.chargerControl === false) {
1172
+ skipChargerOptimization();
1173
+ }
1174
+
1175
+ const id = em.listenForEnergyManagerSettingsChange(async ({ changed, state }) => {
1176
+ if (changed.includes(EnergyManagerSettingEnum.DefaultChargeMode)) {
1177
+ await applyChargeMode(state.values.defaultChargeMode);
1178
+ }
1179
+ });
1180
+ em.unsubscribeEnergyManagerSettings(id);
1181
+ ```
1182
+
1183
+ Three things to get right:
1184
+
1185
+ - **Absent is not `false`.** `undefined` means unsupported or never chosen; `false` means the user switched it off. Check `supported` first, then the value — collapsing the two steers hardware someone deliberately disabled.
1186
+ - **Six settings are gated by another** (`heatingRodMode` needs `heatingRodControl`, `priceLimitCtPerKwh` only applies under `price-limit`, …). The tree is exported as `ENERGY_MANAGER_SETTING_DEPENDENCIES` so the UI and the validator share one source of truth.
1187
+ - **`priceLimitCtPerKwh` is in cents**, unlike the SDK's machine-readable price fields (`electricityPricePerKwh`, EUR/kWh). Divide by 100 when comparing. `validateEnergyManagerSettingsState()` warns on values outside a plausible ct band, which catches the mix-up.
1188
+
1130
1189
  #### `useElectricityTariff(): EnergyAppElectricityTariff`
1131
1190
 
1132
1191
  Register, retrieve, and manage electricity tariffs. One tariff can be marked as the system default.
@@ -1357,6 +1416,38 @@ await energyApp.useOnboardingV2().registerOnboardingGuidesHandler(async (request
1357
1416
 
1358
1417
  `null` and `[]` are **not** the same answer: `[]` says "I genuinely have no guides" and drops the host's cached ones, `null` says "I cannot answer right now" and leaves them alone. Use `null` for transient failures. The host stops waiting after `request.timeoutMs`, so build the guides in memory rather than fetching them.
1359
1418
 
1419
+ Guides declare **slots**, not values: `onboardingV2Block.dynamic(id, kind)` for an OCPP URL or a device IP. Register a second handler to fill them, and return `null` for anything you cannot resolve — a dynamic block is passive content, so an unresolved value never strands a run.
1420
+
1421
+ ```typescript
1422
+ await energyApp.useOnboardingV2().registerDynamicValueHandler(async (request) => {
1423
+ if (request.kind !== EnyoOnboardingV2DynamicKind.OcppUrl) return null;
1424
+
1425
+ const { cloud, local } = await energyApp.useOcpp().getAvailableConnectionDetails();
1426
+ const endpoint = cloud ?? local;
1427
+ if (!endpoint) return null; // unavailable beats wrong
1428
+
1429
+ return { requestId: request.requestId, kind: request.kind, value: endpoint.url };
1430
+ });
1431
+ ```
1432
+
1433
+ Guides can also carry `additional-setup` blocks — the one interactive block **your app** judges. It collects fields (including masked `Password`/`Token` fields, which v2 previously had no way to gather at all), calls a third handler, and branches on the outcome you return:
1434
+
1435
+ ```typescript
1436
+ await energyApp.useOnboardingV2().registerAdditionalSetupHandler(async (request) => {
1437
+ if (request.setupKey !== 'vendor-cloud-token') return { requestId: request.requestId, outcome: 'failed' };
1438
+ const token = request.values.find((v) => v.name === 'api-token')?.value;
1439
+ if (!token || !(await vendorCloud.verify(token))) {
1440
+ return { requestId: request.requestId, outcome: 'invalid' };
1441
+ }
1442
+ await energyApp.useSecretManager().saveSecret('vendor-cloud', { token });
1443
+ return { requestId: request.requestId, outcome: 'connected' };
1444
+ });
1445
+ ```
1446
+
1447
+ 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).
1448
+
1449
+ 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).
1450
+
1360
1451
  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.
1361
1452
 
1362
1453
  Not permission-gated.
@@ -0,0 +1,273 @@
1
+ "use strict";
2
+ /**
3
+ * Client-side validation for {@link EnergyManagerSettingsState} — the general
4
+ * settings an energy manager declares and the values stored against them.
5
+ *
6
+ * Two classes of problem live here, and only the second is obvious:
7
+ *
8
+ * 1. **Malformed values.** A `HH:mm` that is not a time, a timezone that is not
9
+ * an IANA zone, a price that is not a finite number. These fail at planning
10
+ * time, in the dark, on the night the charge was supposed to happen.
11
+ * 2. **Values that can never take effect.** A heating-rod mode stored while rod
12
+ * control is off, a price limit while the default charge mode is
13
+ * cost-optimized. Nothing rejects these — they simply do nothing, which reads
14
+ * to a user as "the app ignored me".
15
+ *
16
+ * The second class is why {@link ENERGY_MANAGER_SETTING_DEPENDENCIES} exists as
17
+ * data: the gate tree is checked here rather than restated by every surface.
18
+ *
19
+ * `errors` mean the state is malformed; `warnings` are advisory. Use
20
+ * {@link validateEnergyManagerSettingsState} for the non-throwing result, or
21
+ * {@link assertValidEnergyManagerSettingsState} to throw.
22
+ */
23
+ Object.defineProperty(exports, "__esModule", { value: true });
24
+ exports.EnergyManagerSettingsValidationError = void 0;
25
+ exports.validateEnergyManagerSettingsState = validateEnergyManagerSettingsState;
26
+ exports.assertValidEnergyManagerSettingsState = assertValidEnergyManagerSettingsState;
27
+ const enyo_energy_manager_settings_js_1 = require("../../types/enyo-energy-manager-settings.cjs");
28
+ const enyo_data_bus_value_js_1 = require("../../types/enyo-data-bus-value.cjs");
29
+ /**
30
+ * Thrown by {@link assertValidEnergyManagerSettingsState} when a state fails
31
+ * validation. The message lists every blocking error so callers can surface them
32
+ * directly.
33
+ */
34
+ class EnergyManagerSettingsValidationError extends Error {
35
+ /** The individual blocking errors that caused the failure. */
36
+ errors;
37
+ /**
38
+ * @param errors - The blocking validation errors.
39
+ */
40
+ constructor(errors) {
41
+ super(`Invalid energy manager settings:\n- ${errors.join('\n- ')}`);
42
+ this.name = 'EnergyManagerSettingsValidationError';
43
+ this.errors = errors;
44
+ }
45
+ }
46
+ exports.EnergyManagerSettingsValidationError = EnergyManagerSettingsValidationError;
47
+ /** Every {@link EnergyManagerSettingEnum} value. */
48
+ const SETTINGS = new Set(Object.values(enyo_energy_manager_settings_js_1.EnergyManagerSettingEnum));
49
+ /** 24-hour `HH:mm`, the format {@link EnergyManagerSettingValues.costOptimizedTargetTime} uses. */
50
+ const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
51
+ /**
52
+ * Plausible band for a price limit in ct/kWh.
53
+ *
54
+ * Wide on purpose, and negative at the bottom: wholesale prices go negative, and
55
+ * "charge only when I am paid to" is a real preference. The band exists to catch
56
+ * a EUR/kWh value entered into a ct/kWh field (`0.07` where `7` was meant) and
57
+ * the reverse (`700`), not to express an opinion about tariffs.
58
+ */
59
+ const PRICE_LIMIT_MIN_CT = -100;
60
+ const PRICE_LIMIT_MAX_CT = 200;
61
+ /**
62
+ * Below this, a {@link EnergyManagerSettingValues.batteryDischargeWhileChargingWh}
63
+ * budget is almost certainly kWh entered into a Wh field.
64
+ *
65
+ * `10` would be ten watt-hours — a rounding error against any house battery, and
66
+ * indistinguishable from "no discharge at all" in practice, where `10` kWh was
67
+ * plainly meant. `0` is exempt: it is a legitimate, if roundabout, way to say
68
+ * disabled.
69
+ */
70
+ const DISCHARGE_BUDGET_MIN_PLAUSIBLE_WH = 100;
71
+ /** Value enums checked per field, for the enum-valued settings. */
72
+ const BATTERY_CHARGING_MODES = new Set(Object.values(enyo_energy_manager_settings_js_1.EnergyManagerBatteryChargingModeEnum));
73
+ const HEATING_ROD_MODES = new Set(Object.values(enyo_energy_manager_settings_js_1.EnergyManagerHeatingRodModeEnum));
74
+ const CHARGE_MODES = new Set(Object.values(enyo_data_bus_value_js_1.EnyoChargeModeEnum));
75
+ /**
76
+ * True when `timezone` is an IANA zone this runtime recognises.
77
+ *
78
+ * Uses `Intl` rather than a pattern: `"Europe/Berlin"` and `"Europe/Berln"` are
79
+ * indistinguishable by shape, and only the first one resolves a wall-clock time.
80
+ *
81
+ * @param timezone - The candidate IANA timezone identifier.
82
+ * @returns True when the runtime accepts it.
83
+ */
84
+ function isValidTimezone(timezone) {
85
+ try {
86
+ new Intl.DateTimeFormat('en-US', { timeZone: timezone });
87
+ return true;
88
+ }
89
+ catch {
90
+ return false;
91
+ }
92
+ }
93
+ /**
94
+ * Reads the effective value a dependency is gated on.
95
+ *
96
+ * @param values - The stored values.
97
+ * @param setting - The gating setting.
98
+ * @returns The gate's current value, or `undefined` when it is unset.
99
+ */
100
+ function gateValue(values, setting) {
101
+ const [key] = enyo_energy_manager_settings_js_1.ENERGY_MANAGER_SETTING_VALUE_KEYS[setting];
102
+ return key ? values[key] : undefined;
103
+ }
104
+ /**
105
+ * Validates an energy manager settings state: the declared support list, the
106
+ * value formats, and the gate tree between them.
107
+ *
108
+ * Checks applied:
109
+ *
110
+ * - `supported` holds only {@link EnergyManagerSettingEnum} members, without
111
+ * duplicates (**error**).
112
+ * - Each stored value has the right type, and each enum-valued setting holds an
113
+ * enum member (**error**).
114
+ * - `costOptimizedTargetTime` is `HH:mm`, and `costOptimizedTimezone` is an IANA
115
+ * zone the runtime resolves (**error**).
116
+ * - `priceLimitCtPerKwh` is a finite number (**error**), and lies in a plausible
117
+ * ct/kWh band (**warning** — catches a EUR/kWh value in a ct/kWh field).
118
+ * - `batteryDischargeWhileChargingWh` is a finite non-negative number or `null`
119
+ * (**error**), and is not a negligible positive figure (**warning** — catches
120
+ * a kWh value in a Wh field).
121
+ * - `blockBatteryDischargeWhileEvCharging` does not sit alongside a positive
122
+ * `batteryDischargeWhileChargingWh` budget (**warning** — the block overrides
123
+ * the budget, so it could never be spent).
124
+ * - A value stored for an unsupported setting (**warning** — the energy manager
125
+ * will ignore it).
126
+ * - A value stored while its gate does not hold (**warning** — it can never take
127
+ * effect; see {@link ENERGY_MANAGER_SETTING_DEPENDENCIES}).
128
+ * - A supported gate whose dependants are unsupported (**warning** — a
129
+ * half-built control surface).
130
+ * - `costOptimizedTargetTime` set without a timezone (**warning** — the
131
+ * wall-clock value is ambiguous across a DST boundary).
132
+ *
133
+ * @param state - The settings state to validate.
134
+ * @returns The {@link EnergyManagerSettingsValidationResult}.
135
+ *
136
+ * @example
137
+ * ```typescript
138
+ * const {ok, errors, warnings} = validateEnergyManagerSettingsState(state);
139
+ * warnings.forEach((w) => console.warn('energy manager settings:', w));
140
+ * if (!ok) console.error('energy manager settings are malformed', errors);
141
+ * ```
142
+ */
143
+ function validateEnergyManagerSettingsState(state) {
144
+ const errors = [];
145
+ const warnings = [];
146
+ if (!state)
147
+ return { ok: false, errors: ['state is required.'], warnings };
148
+ if (!Array.isArray(state.supported)) {
149
+ return { ok: false, errors: ['`supported` must be an array.'], warnings };
150
+ }
151
+ const supported = new Set();
152
+ for (const setting of state.supported) {
153
+ if (!SETTINGS.has(setting)) {
154
+ errors.push(`\`supported\` contains "${setting}", which is not an EnergyManagerSettingEnum member.`);
155
+ }
156
+ else if (supported.has(setting)) {
157
+ errors.push(`\`supported\` lists "${setting}" more than once.`);
158
+ }
159
+ else {
160
+ supported.add(setting);
161
+ }
162
+ }
163
+ const values = state.values ?? {};
164
+ // ── value formats ──────────────────────────────────────────────────────
165
+ for (const key of ['batteryControl', 'batteryChargeFromGrid',
166
+ 'blockBatteryDischargeWhileEvCharging', 'heatpumpControl',
167
+ 'heatingRodControl', 'chargerControl']) {
168
+ if (values[key] !== undefined && typeof values[key] !== 'boolean') {
169
+ errors.push(`\`${key}\` must be a boolean when set.`);
170
+ }
171
+ }
172
+ if (values.batteryChargingMode !== undefined
173
+ && !BATTERY_CHARGING_MODES.has(values.batteryChargingMode)) {
174
+ errors.push(`\`batteryChargingMode\` "${values.batteryChargingMode}" is not an EnergyManagerBatteryChargingModeEnum member.`);
175
+ }
176
+ if (values.heatingRodMode !== undefined && !HEATING_ROD_MODES.has(values.heatingRodMode)) {
177
+ errors.push(`\`heatingRodMode\` "${values.heatingRodMode}" is not an EnergyManagerHeatingRodModeEnum member.`);
178
+ }
179
+ if (values.defaultChargeMode !== undefined && !CHARGE_MODES.has(values.defaultChargeMode)) {
180
+ errors.push(`\`defaultChargeMode\` "${values.defaultChargeMode}" is not an EnyoChargeModeEnum member.`);
181
+ }
182
+ if (values.priceLimitCtPerKwh !== undefined) {
183
+ if (typeof values.priceLimitCtPerKwh !== 'number'
184
+ || !Number.isFinite(values.priceLimitCtPerKwh)) {
185
+ errors.push('`priceLimitCtPerKwh` must be a finite number when set.');
186
+ }
187
+ else if (values.priceLimitCtPerKwh < PRICE_LIMIT_MIN_CT
188
+ || values.priceLimitCtPerKwh > PRICE_LIMIT_MAX_CT) {
189
+ warnings.push(`\`priceLimitCtPerKwh\` is ${values.priceLimitCtPerKwh}, outside the plausible ` +
190
+ `${PRICE_LIMIT_MIN_CT}…${PRICE_LIMIT_MAX_CT} ct/kWh band — this field is in ` +
191
+ 'cents per kWh, not EUR per kWh.');
192
+ }
193
+ }
194
+ if (values.batteryDischargeWhileChargingWh !== undefined
195
+ && values.batteryDischargeWhileChargingWh !== null) {
196
+ const budget = values.batteryDischargeWhileChargingWh;
197
+ if (typeof budget !== 'number' || !Number.isFinite(budget)) {
198
+ errors.push('`batteryDischargeWhileChargingWh` must be a finite number or `null` when set.');
199
+ }
200
+ else if (budget < 0) {
201
+ errors.push(`\`batteryDischargeWhileChargingWh\` is ${budget}; a discharge budget cannot be ` +
202
+ 'negative. Use `null` to disable the transfer entirely.');
203
+ }
204
+ else if (budget > 0 && budget < DISCHARGE_BUDGET_MIN_PLAUSIBLE_WH) {
205
+ warnings.push(`\`batteryDischargeWhileChargingWh\` is ${budget} Wh, which is negligible against ` +
206
+ 'any house battery — this field is in watt-hours, not kilowatt-hours.');
207
+ }
208
+ }
209
+ if (values.blockBatteryDischargeWhileEvCharging === true
210
+ && typeof values.batteryDischargeWhileChargingWh === 'number'
211
+ && values.batteryDischargeWhileChargingWh > 0) {
212
+ warnings.push('`blockBatteryDischargeWhileEvCharging` is true, which closes the tap entirely, so the ' +
213
+ `\`batteryDischargeWhileChargingWh\` budget of ${values.batteryDischargeWhileChargingWh} Wh ` +
214
+ 'can never be spent. Clear one of the two.');
215
+ }
216
+ if (values.costOptimizedTargetTime !== undefined) {
217
+ if (typeof values.costOptimizedTargetTime !== 'string'
218
+ || !TIME_RE.test(values.costOptimizedTargetTime)) {
219
+ errors.push(`\`costOptimizedTargetTime\` "${values.costOptimizedTargetTime}" is not a 24-hour HH:mm time.`);
220
+ }
221
+ if (values.costOptimizedTimezone === undefined) {
222
+ warnings.push('`costOptimizedTargetTime` is set without `costOptimizedTimezone` — the wall-clock ' +
223
+ 'value cannot be resolved unambiguously across a DST boundary.');
224
+ }
225
+ }
226
+ if (values.costOptimizedTimezone !== undefined) {
227
+ if (typeof values.costOptimizedTimezone !== 'string'
228
+ || !isValidTimezone(values.costOptimizedTimezone)) {
229
+ errors.push(`\`costOptimizedTimezone\` "${values.costOptimizedTimezone}" is not an IANA timezone.`);
230
+ }
231
+ }
232
+ // ── stored but unsupported ─────────────────────────────────────────────
233
+ for (const setting of Object.values(enyo_energy_manager_settings_js_1.EnergyManagerSettingEnum)) {
234
+ const keys = enyo_energy_manager_settings_js_1.ENERGY_MANAGER_SETTING_VALUE_KEYS[setting];
235
+ const held = keys.filter((key) => values[key] !== undefined);
236
+ if (held.length && !supported.has(setting)) {
237
+ warnings.push(`"${setting}" holds a value (${held.join(', ')}) but is not in \`supported\` — ` +
238
+ 'the energy manager will ignore it.');
239
+ }
240
+ }
241
+ // ── gate tree ──────────────────────────────────────────────────────────
242
+ for (const [setting, dependency] of Object.entries(enyo_energy_manager_settings_js_1.ENERGY_MANAGER_SETTING_DEPENDENCIES)) {
243
+ if (!dependency)
244
+ continue;
245
+ const key = setting;
246
+ const held = enyo_energy_manager_settings_js_1.ENERGY_MANAGER_SETTING_VALUE_KEYS[key].some((k) => values[k] !== undefined);
247
+ if (held && gateValue(values, dependency.requires) !== dependency.equals) {
248
+ warnings.push(`"${key}" holds a value, but it only takes effect while "${dependency.requires}" ` +
249
+ `is ${JSON.stringify(dependency.equals)} — it currently cannot.`);
250
+ }
251
+ if (supported.has(dependency.requires) && !supported.has(key)
252
+ && gateValue(values, dependency.requires) === dependency.equals) {
253
+ warnings.push(`"${dependency.requires}" is supported and active, but its dependent setting ` +
254
+ `"${key}" is not — the user is offered a gate with nothing behind it.`);
255
+ }
256
+ }
257
+ return { ok: errors.length === 0, errors, warnings };
258
+ }
259
+ /**
260
+ * Like {@link validateEnergyManagerSettingsState}, but throws
261
+ * {@link EnergyManagerSettingsValidationError} when there are blocking errors.
262
+ * Warnings never throw; the validated state is returned on success for chaining.
263
+ *
264
+ * @param state - The settings state to validate.
265
+ * @returns The same state when it has no blocking errors.
266
+ * @throws {EnergyManagerSettingsValidationError} When validation produces any error.
267
+ */
268
+ function assertValidEnergyManagerSettingsState(state) {
269
+ const { ok, errors } = validateEnergyManagerSettingsState(state);
270
+ if (!ok)
271
+ throw new EnergyManagerSettingsValidationError(errors);
272
+ return state;
273
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Client-side validation for {@link EnergyManagerSettingsState} — the general
3
+ * settings an energy manager declares and the values stored against them.
4
+ *
5
+ * Two classes of problem live here, and only the second is obvious:
6
+ *
7
+ * 1. **Malformed values.** A `HH:mm` that is not a time, a timezone that is not
8
+ * an IANA zone, a price that is not a finite number. These fail at planning
9
+ * time, in the dark, on the night the charge was supposed to happen.
10
+ * 2. **Values that can never take effect.** A heating-rod mode stored while rod
11
+ * control is off, a price limit while the default charge mode is
12
+ * cost-optimized. Nothing rejects these — they simply do nothing, which reads
13
+ * to a user as "the app ignored me".
14
+ *
15
+ * The second class is why {@link ENERGY_MANAGER_SETTING_DEPENDENCIES} exists as
16
+ * data: the gate tree is checked here rather than restated by every surface.
17
+ *
18
+ * `errors` mean the state is malformed; `warnings` are advisory. Use
19
+ * {@link validateEnergyManagerSettingsState} for the non-throwing result, or
20
+ * {@link assertValidEnergyManagerSettingsState} to throw.
21
+ */
22
+ import type { EnergyManagerSettingsState } from '../../types/enyo-energy-manager-settings.cjs';
23
+ /**
24
+ * Thrown by {@link assertValidEnergyManagerSettingsState} when a state fails
25
+ * validation. The message lists every blocking error so callers can surface them
26
+ * directly.
27
+ */
28
+ export declare class EnergyManagerSettingsValidationError extends Error {
29
+ /** The individual blocking errors that caused the failure. */
30
+ readonly errors: string[];
31
+ /**
32
+ * @param errors - The blocking validation errors.
33
+ */
34
+ constructor(errors: string[]);
35
+ }
36
+ /** The outcome of validating an {@link EnergyManagerSettingsState}. */
37
+ export interface EnergyManagerSettingsValidationResult {
38
+ /** True when there are no blocking `errors` (warnings are still allowed). */
39
+ ok: boolean;
40
+ /** Blocking problems — the state is malformed. */
41
+ errors: string[];
42
+ /** Advisory problems — allowed, but usually worth fixing. */
43
+ warnings: string[];
44
+ }
45
+ /**
46
+ * Validates an energy manager settings state: the declared support list, the
47
+ * value formats, and the gate tree between them.
48
+ *
49
+ * Checks applied:
50
+ *
51
+ * - `supported` holds only {@link EnergyManagerSettingEnum} members, without
52
+ * duplicates (**error**).
53
+ * - Each stored value has the right type, and each enum-valued setting holds an
54
+ * enum member (**error**).
55
+ * - `costOptimizedTargetTime` is `HH:mm`, and `costOptimizedTimezone` is an IANA
56
+ * zone the runtime resolves (**error**).
57
+ * - `priceLimitCtPerKwh` is a finite number (**error**), and lies in a plausible
58
+ * ct/kWh band (**warning** — catches a EUR/kWh value in a ct/kWh field).
59
+ * - `batteryDischargeWhileChargingWh` is a finite non-negative number or `null`
60
+ * (**error**), and is not a negligible positive figure (**warning** — catches
61
+ * a kWh value in a Wh field).
62
+ * - `blockBatteryDischargeWhileEvCharging` does not sit alongside a positive
63
+ * `batteryDischargeWhileChargingWh` budget (**warning** — the block overrides
64
+ * the budget, so it could never be spent).
65
+ * - A value stored for an unsupported setting (**warning** — the energy manager
66
+ * will ignore it).
67
+ * - A value stored while its gate does not hold (**warning** — it can never take
68
+ * effect; see {@link ENERGY_MANAGER_SETTING_DEPENDENCIES}).
69
+ * - A supported gate whose dependants are unsupported (**warning** — a
70
+ * half-built control surface).
71
+ * - `costOptimizedTargetTime` set without a timezone (**warning** — the
72
+ * wall-clock value is ambiguous across a DST boundary).
73
+ *
74
+ * @param state - The settings state to validate.
75
+ * @returns The {@link EnergyManagerSettingsValidationResult}.
76
+ *
77
+ * @example
78
+ * ```typescript
79
+ * const {ok, errors, warnings} = validateEnergyManagerSettingsState(state);
80
+ * warnings.forEach((w) => console.warn('energy manager settings:', w));
81
+ * if (!ok) console.error('energy manager settings are malformed', errors);
82
+ * ```
83
+ */
84
+ export declare function validateEnergyManagerSettingsState(state: EnergyManagerSettingsState): EnergyManagerSettingsValidationResult;
85
+ /**
86
+ * Like {@link validateEnergyManagerSettingsState}, but throws
87
+ * {@link EnergyManagerSettingsValidationError} when there are blocking errors.
88
+ * Warnings never throw; the validated state is returned on success for chaining.
89
+ *
90
+ * @param state - The settings state to validate.
91
+ * @returns The same state when it has no blocking errors.
92
+ * @throws {EnergyManagerSettingsValidationError} When validation produces any error.
93
+ */
94
+ export declare function assertValidEnergyManagerSettingsState(state: EnergyManagerSettingsState): EnergyManagerSettingsState;
@@ -15,6 +15,7 @@ exports.onboardingV2Target = exports.onboardingV2Block = void 0;
15
15
  exports.defineOnboardingGuideV2 = defineOnboardingGuideV2;
16
16
  exports.onContinueV2 = onContinueV2;
17
17
  exports.onOptionV2 = onOptionV2;
18
+ exports.onSkipV2 = onSkipV2;
18
19
  exports.onOutcomeV2 = onOutcomeV2;
19
20
  const enyo_onboarding_v2_js_1 = require("../../types/enyo-onboarding-v2.cjs");
20
21
  // ---------------------------------------------------------------------------
@@ -228,7 +229,9 @@ exports.onboardingV2Block = {
228
229
  * @param id - Stable block id, unique within the guide.
229
230
  * @param label - Translated sign-in button text (de/en).
230
231
  * @param outcome - The single success handle (`{id, label}`).
231
- * @param opts - Optional translated `help` naming the account that is needed.
232
+ * @param opts - Optional translated `help` naming the account that is needed,
233
+ * and `requiresWebAuthentication` to force the login into a web browser
234
+ * when the provider rejects a custom-scheme redirect such as `enyoapp://`.
232
235
  */
233
236
  auth: (id, label, outcome, opts) => ({
234
237
  id,
@@ -236,6 +239,7 @@ exports.onboardingV2Block = {
236
239
  label,
237
240
  outcome,
238
241
  help: opts?.help,
242
+ requiresWebAuthentication: opts?.requiresWebAuthentication,
239
243
  }),
240
244
  /**
241
245
  * A link block: a fixed URL the installer opens or copies.
@@ -282,6 +286,57 @@ exports.onboardingV2Block = {
282
286
  placeholder: opts?.placeholder,
283
287
  help: opts?.help,
284
288
  }),
289
+ /**
290
+ * An additional-setup block: collect zero or more values, hand them to this
291
+ * energy app, and branch on the verdict the app returns.
292
+ *
293
+ * The only interactive block the app itself judges — use it for a vendor API
294
+ * key, a service token, an installer code. Keep
295
+ * {@link onboardingV2Block.auth} for the app's own OAuth session, whose
296
+ * server gating this cannot replace.
297
+ *
298
+ * `outcomes` must contain at least two entries, one of them valued `failed`
299
+ * ({@link ENYO_ONBOARDING_V2_SETUP_FAILED_OUTCOME}) — every breakdown lands
300
+ * there, including an outcome the handler returns that no branch declares.
301
+ * Route each outcome with {@link onOutcomeV2} and the optional skip with
302
+ * {@link onSkipV2}.
303
+ *
304
+ * `Password` and `Token` fields are treated as secrets: masked, never logged,
305
+ * never prefilled, and not persisted in run state.
306
+ *
307
+ * @param id - Stable block id, unique within the guide.
308
+ * @param setupKey - App-facing key the handler switches on; may repeat across guides.
309
+ * @param opts - `cta` and `description` (both translated, both required),
310
+ * optional `fields`, the `outcomes`, and an optional `skip` handle.
311
+ *
312
+ * @example
313
+ * ```typescript
314
+ * onboardingV2Block.additionalSetup('cloud', 'vendor-cloud-token', {
315
+ * cta: t('Cloud verbinden', 'Connect the cloud'),
316
+ * description: t('Optional: bessere Prognosen.', 'Optional: better forecasts.'),
317
+ * fields: [{
318
+ * name: 'api-token',
319
+ * type: EnyoOnboardingV2SetupFieldType.Token,
320
+ * label: t('API-Token', 'API token'),
321
+ * }],
322
+ * outcomes: [
323
+ * {id: 'ok', value: 'connected', label: t('Verbunden', 'Connected')},
324
+ * {id: 'failed', value: 'failed', label: t('Fehlgeschlagen', 'Failed')},
325
+ * ],
326
+ * skip: {id: 'later', label: t('Später', 'Later')},
327
+ * })
328
+ * ```
329
+ */
330
+ additionalSetup: (id, setupKey, opts) => ({
331
+ id,
332
+ type: enyo_onboarding_v2_js_1.EnyoOnboardingV2BlockType.AdditionalSetup,
333
+ setupKey,
334
+ cta: opts.cta,
335
+ description: opts.description,
336
+ fields: opts.fields,
337
+ outcomes: opts.outcomes,
338
+ skip: opts.skip,
339
+ }),
285
340
  };
286
341
  // ---------------------------------------------------------------------------
287
342
  // Target factories
@@ -364,6 +419,26 @@ function onOptionV2(blockId, optionId, to, note) {
364
419
  note,
365
420
  };
366
421
  }
422
+ /**
423
+ * Route the skip handle of an `AdditionalSetup` block.
424
+ *
425
+ * Only an optional setup has one. It leaves the block without a verdict, so
426
+ * nothing is collected and the handler is never called — which is what keeps a
427
+ * failing bonus feature from blocking an otherwise finished onboarding.
428
+ *
429
+ * @param blockId - The additional-setup block's id.
430
+ * @param skipId - The skip handle's id.
431
+ * @param to - Where skipping leads.
432
+ * @param note - Optional author note.
433
+ */
434
+ function onSkipV2(blockId, skipId, to, note) {
435
+ return {
436
+ id: `skip:${blockId}:${skipId}`,
437
+ source: { kind: enyo_onboarding_v2_js_1.EnyoOnboardingV2TransitionSourceKind.Skip, blockId, skipId },
438
+ target: to,
439
+ note,
440
+ };
441
+ }
367
442
  /**
368
443
  * Route an `Action` outcome.
369
444
  * @param blockId - The action block's id.