@enyo-energy/energy-app-sdk 1.37.0 → 1.39.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 (40) hide show
  1. package/README.md +239 -48
  2. package/dist/cjs/energy-app-package-definition.d.cts +19 -3
  3. package/dist/cjs/enyo-energy-app-sdk.cjs +7 -1
  4. package/dist/cjs/enyo-energy-app-sdk.d.cts +7 -1
  5. package/dist/cjs/implementations/subscriptions/in-memory-subscriptions.cjs +87 -21
  6. package/dist/cjs/implementations/subscriptions/in-memory-subscriptions.d.cts +59 -13
  7. package/dist/cjs/implementations/subscriptions/subscription-plan-validators.cjs +77 -8
  8. package/dist/cjs/implementations/subscriptions/subscription-plan-validators.d.cts +50 -9
  9. package/dist/cjs/packages/energy-app-appliance.d.cts +4 -0
  10. package/dist/cjs/packages/energy-app-notification.d.cts +164 -2
  11. package/dist/cjs/packages/energy-app-subscriptions.d.cts +51 -26
  12. package/dist/cjs/packages/energy-app-vehicle.d.cts +12 -4
  13. package/dist/cjs/types/enyo-appliance.d.cts +10 -0
  14. package/dist/cjs/types/enyo-notification.d.cts +55 -2
  15. package/dist/cjs/types/enyo-subscription.cjs +26 -3
  16. package/dist/cjs/types/enyo-subscription.d.cts +85 -2
  17. package/dist/cjs/types/enyo-vehicle.cjs +27 -1
  18. package/dist/cjs/types/enyo-vehicle.d.cts +65 -1
  19. package/dist/cjs/version.cjs +1 -1
  20. package/dist/cjs/version.d.cts +1 -1
  21. package/dist/energy-app-package-definition.d.ts +19 -3
  22. package/dist/enyo-energy-app-sdk.d.ts +7 -1
  23. package/dist/enyo-energy-app-sdk.js +7 -1
  24. package/dist/implementations/subscriptions/in-memory-subscriptions.d.ts +59 -13
  25. package/dist/implementations/subscriptions/in-memory-subscriptions.js +89 -23
  26. package/dist/implementations/subscriptions/subscription-plan-validators.d.ts +50 -9
  27. package/dist/implementations/subscriptions/subscription-plan-validators.js +76 -9
  28. package/dist/packages/energy-app-appliance.d.ts +4 -0
  29. package/dist/packages/energy-app-notification.d.ts +164 -2
  30. package/dist/packages/energy-app-subscriptions.d.ts +51 -26
  31. package/dist/packages/energy-app-vehicle.d.ts +12 -4
  32. package/dist/types/enyo-appliance.d.ts +10 -0
  33. package/dist/types/enyo-notification.d.ts +55 -2
  34. package/dist/types/enyo-subscription.d.ts +85 -2
  35. package/dist/types/enyo-subscription.js +25 -2
  36. package/dist/types/enyo-vehicle.d.ts +65 -1
  37. package/dist/types/enyo-vehicle.js +26 -0
  38. package/dist/version.d.ts +1 -1
  39. package/dist/version.js +1 -1
  40. package/package.json +1 -1
package/README.md CHANGED
@@ -244,7 +244,8 @@ energyApp.onShutdown(async () => {
244
244
  - `running`: App is functioning normally
245
245
  - `configuration-required`: App needs user configuration
246
246
  - `internet-connection-required`: App needs internet connectivity
247
- - `subscription-required`: App needs a subscription plan; the host shows the plans declared in the package definition
247
+ - `subscription-required`: App needs a subscription plan; the host shows the plans declared in the package definition.
248
+ Only for packages with `subscriptionMode: 'required'` — see [Subscription mode](#subscription-mode)
248
249
 
249
250
  ### Package Definition
250
251
 
@@ -886,8 +887,10 @@ vehicles and it does not go looking for them — the flow starts with the user:
886
887
  ```typescript
887
888
  const vehicles = energyApp.useVehicle();
888
889
 
889
- vehicles.onPairVehicle(async (vehicle, selection) => {
890
- const cars = await vendorApi.listVehicles();
890
+ vehicles.onPairVehicle(async (vehicle, selection, context) => {
891
+ // The brand the user selected in the enyo app, from the package's `compatibility`
892
+ // vendors — lets an aggregator package pick the right vendor API.
893
+ const cars = await vendorApi.listVehicles(context?.vendorName);
891
894
 
892
895
  // Several cars in the account and nothing to tell them apart —
893
896
  // hand the choice back to the user instead of guessing
@@ -929,6 +932,9 @@ vehicles.onPairVehicle(async (vehicle, selection) => {
929
932
 
930
933
  The handler is called a second time with `selection` after the user picks from
931
934
  `candidates`. A package serving single-car accounts can ignore the parameter.
935
+ `context.vendorName` is the vendor the user selected and is passed on both calls; it is
936
+ also kept on the link (`EnyoLinkedVehicle.vendorName`) so the package can read it back
937
+ after a restart.
932
938
 
933
939
  Once paired, the package has the `vehicleId` it was always missing and publishes
934
940
  readings the normal way — `VehicleSocUpdateV1`, keyed by that id. Nothing about
@@ -947,9 +953,11 @@ vehicles.onUnpairVehicle(async (link, reason) => {
947
953
  }
948
954
  });
949
955
 
950
- // The car vanished from the vendor account, or the subscription lapsed.
956
+ // The car vanished from the vendor account, or the vendor revoked access.
951
957
  // Does NOT call onUnpairVehicle — the package already knows.
952
- await vehicles.unpairVehicle(vehicleId);
958
+ // The reason decides how a billed car is billed — see "What a removal means for billing".
959
+ await vehicles.unpairVehicle(vehicleId, EnyoVehiclePackageUnpairReasonEnum.VehicleNotFound);
960
+ await vehicles.unpairVehicle(otherVehicleId, EnyoVehiclePackageUnpairReasonEnum.AccessRevoked);
953
961
 
954
962
  // Handlers only fire on change, so pick the links back up after a restart
955
963
  for (const link of await vehicles.listLinkedVehicles()) {
@@ -1282,32 +1290,150 @@ prices.onPriceChange((newPrice) => {
1282
1290
 
1283
1291
  #### `useNotification(): EnergyAppNotification`
1284
1292
 
1285
- Send notifications to users:
1293
+ Send notifications to users. Every category must be registered before it is used; the host uses the category for the
1294
+ user's on/off switch and for rate limits.
1286
1295
 
1287
1296
  ```typescript
1288
1297
  const notifications = energyApp.useNotification();
1289
1298
 
1290
- // Send info notification
1291
- await notifications.sendNotification({
1292
- type: 'info',
1293
- title: 'Energy Optimization',
1294
- message: 'Your system is running optimally',
1295
- timestamp: Date.now()
1299
+ notifications.registerNotificationCategory({
1300
+ category: 'connection-issue',
1301
+ name: [
1302
+ { language: 'en', name: 'Connection issue' },
1303
+ { language: 'de', name: 'Verbindungsproblem' }
1304
+ ]
1296
1305
  });
1297
1306
 
1298
- // Send warning
1299
- await notifications.sendNotification({
1300
- type: 'warning',
1301
- title: 'High Energy Consumption',
1302
- message: 'Consider reducing energy usage during peak hours'
1303
- });
1307
+ const notificationId = notifications.sendNotification(
1308
+ 'connection-issue',
1309
+ { applianceId, target: { type: 'appliance-settings', applianceId } },
1310
+ [
1311
+ { language: 'en', title: 'Inverter offline', body: 'Inverter communication lost - please check the connection.' },
1312
+ { language: 'de', title: 'Wechselrichter offline', body: 'Keine Verbindung zum Wechselrichter - bitte Verbindung prüfen.' }
1313
+ ]
1314
+ );
1315
+ ```
1316
+
1317
+ Categories:
1318
+
1319
+ | Category | Use when |
1320
+ |---|---|
1321
+ | `connection-issue` | An appliance, gateway or upstream service is unreachable. |
1322
+ | `onboarding-required` | The user has to complete (or revisit) an onboarding flow. |
1323
+ | `charging-issue` | The app asked a charger to charge, but it is not charging. See [Charging issues](#charging-issues). |
1324
+ | `battery-issue` | The app commanded a battery, but it does not follow the command. See [Battery issues](#battery-issues). |
1325
+ | `authentication-required` | A sign-in that used to work was lost. See [Lost sign-in](#lost-sign-in). |
1326
+
1327
+ Targets (`options.target`) decide where tapping the notification leads:
1328
+
1329
+ | Target | Opens |
1330
+ |---|---|
1331
+ | `{ type: 'onboarding', onboardingName }` | A registered onboarding guide. |
1332
+ | `{ type: 'energy-app-settings' }` | The package's own settings. |
1333
+ | `{ type: 'appliance-settings', applianceId }` | The settings screen of an appliance. |
1334
+ | `{ type: 'appliance', applianceId }` | The live view of an appliance (current state, power and session). |
1335
+ | `{ type: 'authentication', applianceId? }` | The package's pending sign-in, created with `requestAuthentication()`. |
1336
+ | `{ type: 'subscription-offer', planKey }` | The offer screen for a subscription plan. |
1337
+
1338
+ `sendNotification` throws an `EnyoNotificationSendError` with `type: 'not-registered'` if the category was not
1339
+ registered, or `type: 'rate-limit'` if the host's rate limit for the category is exceeded.
1340
+
1341
+ ##### Charging issues
1342
+
1343
+ Only the app that decided a charger should charge knows that it should be charging, so the app sends
1344
+ `charging-issue` notifications. The host relies on these rules and does not re-check them. Send one only when
1345
+ **all** of the following hold:
1346
+
1347
+ - The app itself asked the charger to charge: it sent a start or resume, or it is giving the charger more than 0 W.
1348
+ - The charger has stayed non-charging for at least 5 minutes after that request (status is not `Charging`, or it is
1349
+ `Charging` but draws almost no power).
1350
+ - The charger is not `Suspended` with reason `EnyoChargerApplianceSuspendedReasonEnum.Ev`: the car is full or paused
1351
+ charging itself, which is not a fault.
1352
+ - No grid-operator limit (§14a / `GridOperatorPowerLimitationV1`) is currently pushing the charger to 0.
1353
+
1354
+ Send it **at most once per plug-in**: wait until the charger is `Available` again (car unplugged) before sending
1355
+ another one for that appliance. Always pass `applianceId` and the `appliance` target. The host drops more than one
1356
+ `charging-issue` per appliance per 30 minutes with a `rate-limit` error.
1357
+
1358
+ ```typescript
1359
+ const notification = energyApp.useNotification();
1304
1360
 
1305
- // Send critical alert
1306
- await notifications.sendNotification({
1307
- type: 'error',
1308
- title: 'System Fault',
1309
- message: 'Inverter communication lost - please check connection'
1361
+ notification.registerNotificationCategory({
1362
+ category: 'charging-issue',
1363
+ name: [
1364
+ {language: 'de', name: 'Ladeprobleme'},
1365
+ {language: 'en', name: 'Charging issues'},
1366
+ ],
1367
+ description: [
1368
+ {language: 'de', name: 'Ladeprobleme', description: 'Wenn dein Auto nicht lädt, obwohl es laden sollte.'},
1369
+ {language: 'en', name: 'Charging issues', description: 'When your car is not charging although it should.'},
1370
+ ],
1310
1371
  });
1372
+
1373
+ notification.sendNotification(
1374
+ 'charging-issue',
1375
+ {applianceId, target: {type: 'appliance', applianceId}},
1376
+ [
1377
+ {language: 'de', title: 'Dein Auto lädt nicht', body: 'Die Wallbox sollte gerade laden, tut es aber nicht. Prüfe bitte Auto und Ladekabel.'},
1378
+ {language: 'en', title: 'Your car is not charging', body: 'The wallbox should be charging right now but isn\'t. Please check the car and the charging cable.'},
1379
+ ],
1380
+ );
1381
+ ```
1382
+
1383
+ ##### Battery issues
1384
+
1385
+ `battery-issue` follows the same idea for home batteries. Send one only when **all** of the following hold:
1386
+
1387
+ - The app itself commanded the battery (`SetStorageControlV2` with mode `Controlled`, or one of the other storage
1388
+ commands) and that command is still in effect. Never while the battery runs on `Auto`.
1389
+ - For at least 5 minutes the battery has not followed it, judged by `batteryPowerW` in `BatteryValuesUpdateV1`:
1390
+ not charging on `Charge`, not discharging on `Discharge`, or moving power noticeably on `Hold`.
1391
+ - The battery is not simply at its limit: no charge command at or above `maxSoC` (or 100 %), no discharge command
1392
+ at or below `minSoC`.
1393
+ - A discharge to the house is not just capped by low household consumption.
1394
+ - No grid-operator limit (§14a) is pushing the battery to 0, and `controlAllowed` is not `false`.
1395
+
1396
+ Send it **at most once per issue**: wait until the battery follows a command again, or the app releases it to
1397
+ `Auto`, before sending another one. Always pass `applianceId` and the `appliance` target. The host drops more than
1398
+ one `battery-issue` per appliance per 30 minutes with a `rate-limit` error.
1399
+
1400
+ ```typescript
1401
+ notification.sendNotification(
1402
+ 'battery-issue',
1403
+ {applianceId, target: {type: 'appliance', applianceId}},
1404
+ [
1405
+ {language: 'de', title: 'Dein Speicher reagiert nicht', body: 'Der Batteriespeicher folgt der Steuerung gerade nicht. Prüfe bitte, ob die externe Steuerung am Speicher aktiviert ist.'},
1406
+ {language: 'en', title: 'Your battery is not responding', body: 'The home battery is not following the control right now. Please check that external control is enabled on the battery.'},
1407
+ ],
1408
+ );
1409
+ ```
1410
+
1411
+ ##### Lost sign-in
1412
+
1413
+ `requestAuthentication()` already prompts the user, including for the first sign-in during onboarding.
1414
+ `authentication-required` is only for a sign-in that **used to work** and was lost. Send one only when:
1415
+
1416
+ - The package was `Authenticated` before, the vendor now rejects the credentials or token, and a refresh failed.
1417
+ - The failure is not transient. Timeouts or a vendor outage are a `connection-issue`.
1418
+ - The user did not sign out themselves.
1419
+
1420
+ Before sending, set the state with `setAuthenticationState()` and create a new request with
1421
+ `requestAuthentication()`, so the `authentication` target has a sign-in to open. Send it **at most once per lost
1422
+ session**, and not again until the package is `Authenticated` again. The host allows one per package (or per
1423
+ appliance, for an appliance sign-in) per 24 hours.
1424
+
1425
+ ```typescript
1426
+ await auth.setAuthenticationState({state: EnyoAuthenticationStateEnum.AuthenticationFailed});
1427
+ await auth.requestAuthentication({authenticationType: 'oauth', oauth: {}, oneTimeAuthentication: false});
1428
+
1429
+ notification.sendNotification(
1430
+ 'authentication-required',
1431
+ {target: {type: 'authentication'}},
1432
+ [
1433
+ {language: 'de', title: 'Bitte erneut anmelden', body: 'Deine Anmeldung ist abgelaufen. Melde dich erneut an, damit wir deine Geräte weiter steuern können.'},
1434
+ {language: 'en', title: 'Please sign in again', body: 'Your sign-in has expired. Sign in again so we can keep controlling your devices.'},
1435
+ ],
1436
+ );
1311
1437
  ```
1312
1438
 
1313
1439
  ### App Intelligence
@@ -2322,7 +2448,8 @@ subscriptions.listenForSubscriptionChanges(async ({ subscription, previousStatus
2322
2448
  - **Per-unit plans:** report the units with `setUnits(planKey, units)` and check each one with
2323
2449
  `isUnitActive(subscriptions, planKey, unit)` — see [Per-unit plans](#per-unit-plans).
2324
2450
  - **Point the user to an offer** with a notification target `{ type: 'subscription-offer', planKey: 'premium' }`,
2325
- or set `EnergyAppStateEnum.SubscriptionRequired` when the whole app needs a plan.
2451
+ or set `EnergyAppStateEnum.SubscriptionRequired` when the whole app needs a plan (`subscriptionMode: 'required'`
2452
+ only).
2326
2453
 
2327
2454
  For tests, `InMemorySubscriptions` implements the same interface; `upsertSubscription()` plays the part of enyo
2328
2455
  and notifies the listeners.
@@ -2949,6 +3076,7 @@ import {
2949
3076
  defineSubscriptionPlan,
2950
3077
  EnergyAppPermissionTypeEnum,
2951
3078
  EnyoCurrencyEnum,
3079
+ EnyoSubscriptionModeEnum,
2952
3080
  EnyoSubscriptionPaymentIntervalEnum,
2953
3081
  validatePackageSubscriptionPlans
2954
3082
  } from '@enyo-energy/energy-app-sdk';
@@ -2956,6 +3084,7 @@ import {
2956
3084
  const packageDef = defineEnergyAppPackage({
2957
3085
  // ...
2958
3086
  permissions: [EnergyAppPermissionTypeEnum.Subscriptions /* , ... */],
3087
+ subscriptionMode: EnyoSubscriptionModeEnum.Required, // see "Subscription mode" below
2959
3088
  subscriptionPlans: [
2960
3089
  defineSubscriptionPlan({
2961
3090
  planKey: 'premium',
@@ -2985,7 +3114,7 @@ const packageDef = defineEnergyAppPackage({
2985
3114
  |---|---|
2986
3115
  | `planKey` | Kebab-case slug, unique in the package. Subscriptions refer to the plan by it — treat it as an API. |
2987
3116
  | `paymentInterval` | `one-time`, `monthly` or `yearly`. One-time purchases never renew and stay `active` until refunded. |
2988
- | `pricingModel` | `per-package` (default): one price for the package on the device. `per-unit`: the price is charged per unit the app reports — see [Per-unit plans](#per-unit-plans). |
3117
+ | `pricingModel` | `per-package` (default): one price for the package on the device. `per-unit`: the price is charged per unit — see [Per-unit plans](#per-unit-plans). |
2989
3118
  | `prices` | One net price per currency. `amountWithoutTax` is always the **total for one interval** — per month, per year, or the single one-time amount; per unit for `per-unit` plans — in the currency (not cent), at most two decimals; enyo adds VAT. |
2990
3119
  | `trialDays` | Optional free trial in days. Not allowed for `one-time` plans. |
2991
3120
  | `storeEntry` | Title, short description and optional benefits; `de` and `en` are required. |
@@ -2994,51 +3123,113 @@ const packageDef = defineEnergyAppPackage({
2994
3123
  What a plan unlocks is decided by the app: check `isPlanActive(subscriptions, planKey)` at runtime (see
2995
3124
  [`useSubscriptions()`](#usesubscriptions-energyappsubscriptions)).
2996
3125
 
3126
+ ### Subscription mode
3127
+
3128
+ As soon as a package declares `subscriptionPlans`, it must also declare `subscriptionMode`: does the app work
3129
+ without a plan at all? enyo uses it to decide what to offer — a free base option next to the plans, a downgrade to
3130
+ that base in the subscription management, and the wording of the cancellation and uninstall texts.
3131
+
3132
+ | `subscriptionMode` | Meaning | App state without an active plan |
3133
+ |---|---|---|
3134
+ | `required` | The app sells its whole function; without a plan it does nothing useful. enyo offers no free option, and cancelling warns that the app stops working. | Reports `SubscriptionRequired`, and `Running` again once a plan is active. |
3135
+ | `optional` | The app has a free base; its plans add features. enyo offers the free base and lets the customer downgrade to it. | Keeps running. **Never** reports `SubscriptionRequired` — gate only the paid features with `isPlanActive()` / `isUnitActive()`. |
3136
+
3137
+ ```typescript
3138
+ defineEnergyAppPackage({
3139
+ // ...
3140
+ subscriptionMode: EnyoSubscriptionModeEnum.Optional,
3141
+ subscriptionPlans: [/* price optimisation on top of free basic control */]
3142
+ });
3143
+ ```
3144
+
3145
+ In tests, `InMemorySubscriptions` takes the mode as its third constructor argument; `assertEnergyAppState(state)`
3146
+ throws when an `optional` package reports `SubscriptionRequired`. The same check is available as
3147
+ `assertEnergyAppStateMatchesSubscriptionMode(mode, state)`.
3148
+
2997
3149
  ### Per-unit plans
2998
3150
 
2999
- For a `per-unit` plan the price is charged once per unit, and the **quantity comes from the energy app**. The app
3000
- reports every unit it currently manages with `setUnits()` — on boot and whenever a unit is added or removed. Each
3001
- unit has a `type` and a `referenceId` it is paid under:
3151
+ For a `per-unit` plan the price is charged once per unit — 3 units on a yearly plan for 10 € cost 30 € per year
3152
+ (net). Each unit has a `type` and a `referenceId` it is paid under:
3002
3153
 
3003
3154
  | `type` | `referenceId` | Name shown to the customer |
3004
3155
  |---|---|---|
3005
- | `appliance` | the appliance id | taken from the appliance |
3006
- | `vehicle` | the vehicle id | taken from the vehicle |
3156
+ | `appliance` | the enyo appliance id (`EnyoAppliance.id`) | taken from the appliance |
3157
+ | `vehicle` | the enyo vehicle id (`EnyoVehicle.id` = `EnyoLinkedVehicle.vehicleId`), **never** the vendor's `externalId` | taken from the vehicle |
3007
3158
  | `custom` | an id chosen by the app | `name`, translated; `de` and `en` are required |
3008
3159
 
3160
+ Where a unit comes from depends on its type:
3161
+
3162
+ | Unit | How the app declares it | Who keeps the list |
3163
+ |---|---|---|
3164
+ | Appliance | `billing: { billed: true, planKey }` on the appliance it saves | The host — added on save, removed when the appliance is removed or the mark is cleared |
3165
+ | Vehicle | `billing: { billed: true, planKey }` in the pair result | The host — added on pairing, removed on unpairing |
3166
+ | Custom | `setUnits(planKey, units)` — on boot and whenever one is added or removed | The app |
3167
+
3168
+ Marking appliances and vehicles where they are created keeps the billed units in step with what is actually
3169
+ connected, gives every unit the right enyo id, and lets the host bill a removal by its reason (see below). An app
3170
+ that also creates appliances it doesn't bill, such as a meter next to the heat pump, simply leaves `billing` off
3171
+ them. `onUnpairVehicle` and `listenForApplianceRemoved` then only need to stop serving the unit. `billing.planKey`
3172
+ must name a `per-unit` plan of the package; check it with `validateSubscriptionBilling(billing, plans)`.
3173
+
3174
+ ```typescript
3175
+ // A heat pump billed per unit; the meter the app also creates is not billed.
3176
+ await energyApp.useAppliances().save({
3177
+ ...heatpump,
3178
+ billing: { billed: true, planKey: 'heatpump-optimisation' }
3179
+ });
3180
+ await energyApp.useAppliances().save(meter);
3181
+
3182
+ // A car billed per unit, marked in the pair result.
3183
+ vehicles.onPairVehicle(async (vehicle, selection) => ({
3184
+ paired: true,
3185
+ externalId: car.id,
3186
+ billing: { billed: true, planKey: 'smartcar-vehicle' }
3187
+ }));
3188
+ ```
3189
+
3190
+ For anything else, report custom units yourself. `setUnits()` only takes custom units:
3191
+
3009
3192
  ```typescript
3010
3193
  import { EnyoSubscriptionUnitTypeEnum, isUnitActive } from '@enyo-energy/energy-app-sdk';
3011
3194
 
3012
3195
  const subscriptions = energyApp.useSubscriptions();
3013
- const wallboxes = await energyApp.useAppliances().list();
3014
-
3015
- // 3 wallboxes on a yearly plan for 10 € → 30 € per year (net).
3016
- await subscriptions.setUnits('smart-charging', wallboxes.map(wallbox => ({
3017
- type: EnyoSubscriptionUnitTypeEnum.Appliance,
3018
- referenceId: wallbox.id
3019
- })));
3020
3196
 
3021
- // A custom unit carries its own translated name.
3022
3197
  await subscriptions.setUnits('room-climate', [{
3023
3198
  type: EnyoSubscriptionUnitTypeEnum.Custom,
3024
3199
  referenceId: 'living-room',
3025
3200
  name: [{ language: 'en', value: 'Living room' }, { language: 'de', value: 'Wohnzimmer' }]
3026
3201
  }]);
3027
3202
 
3028
- // Only the units the customer paid for are active.
3203
+ // Whatever the type: only the units the customer paid for are active.
3029
3204
  const all = await subscriptions.getSubscriptions();
3030
- for (const wallbox of wallboxes) {
3031
- setSmartCharging(wallbox, isUnitActive(all, 'smart-charging', { type: 'appliance', referenceId: wallbox.id }));
3032
- }
3205
+ setClimateControl('living-room', isUnitActive(all, 'room-climate', { type: 'custom', referenceId: 'living-room' }));
3206
+ setOptimisation(heatpump, isUnitActive(all, 'heatpump-optimisation', { type: 'appliance', referenceId: heatpump.id }));
3033
3207
  ```
3034
3208
 
3209
+ `getUnits(planKey)` returns all units of a plan — the billed appliances and vehicles plus the custom units.
3210
+
3035
3211
  - **Reporting a unit does not pay for it.** Paid units are listed in `subscription.units`; when the customer pays
3036
- for more units, the change listeners are called.
3212
+ for more units, or a unit is removed, the change listeners are called.
3037
3213
  - **Keep `referenceId` stable.** `type` plus `referenceId` identify the unit; a new reference is a new, unpaid unit.
3038
- - **enyo decides how a changed quantity is charged**, e.g. pro rata for the rest of the period.
3039
3214
  - `validateSubscriptionUnits(units)` applies the host's rules up front: known type, non-empty reference, no unit
3040
3215
  twice, `de` and `en` names on custom units.
3041
3216
 
3217
+ #### What a removal means for billing
3218
+
3219
+ The host decides, never the app's unit list:
3220
+
3221
+ | Unit leaves because… | Billing |
3222
+ |---|---|
3223
+ | The customer confirmed „Trennen" / „Entfernen" in the enyo app (vehicle unpaired by the user, appliance removed by the user) | The quantity drops at the next renewal; removing the last unit cancels the subscription at the end of the period. Nothing is refunded. |
3224
+ | The vehicle was deleted in the enyo app | Same as above. |
3225
+ | The app unlinked the car because it is gone from the vendor account — `unpairVehicle(id, EnyoVehiclePackageUnpairReasonEnum.VehicleNotFound)` | Same as above. |
3226
+ | Vendor sign-out | **Unchanged.** Usually followed by signing in again; a paid unit stays paid. |
3227
+ | The app unlinked the car because the vendor revoked access — `unpairVehicle(id, EnyoVehiclePackageUnpairReasonEnum.AccessRevoked)` | Unchanged — access is usually granted again. |
3228
+ | Unknown reason, `unpairVehicle()` without a reason, or the app's own report shrinks | Unchanged. |
3229
+
3230
+ A shorter unit list alone — an expired token, an app bug — never cancels anything a customer paid for. Every
3231
+ change takes effect at the next renewal or at the end of the paid period; there are no pro-rata refunds.
3232
+
3042
3233
  **A price change needs a new release** and only applies to new purchases — running subscriptions keep the price
3043
3234
  they were bought at.
3044
3235
 
@@ -3049,10 +3240,10 @@ const { ok, errors } = validatePackageSubscriptionPlans(packageDef);
3049
3240
  // or: assertValidPackageSubscriptionPlans(packageDef) — throws PackageSubscriptionPlansValidationError
3050
3241
  ```
3051
3242
 
3052
- It checks that plan keys are unique slugs, that the payment interval and pricing model are known, that every plan has at least one
3053
- positive price per currency with at most two decimals, that `trialDays` is a positive whole number and not set on a
3054
- `one-time` plan, that `de` and
3055
- `en` store entries exist, and that the package requests the `Subscriptions` permission.
3243
+ It checks that `subscriptionMode` is declared, that plan keys are unique slugs, that the payment interval and
3244
+ pricing model are known, that every plan has at least one positive price per currency with at most two decimals,
3245
+ that `trialDays` is a positive whole number and not set on a `one-time` plan, that `de` and `en` store entries
3246
+ exist, and that the package requests the `Subscriptions` permission. A `subscriptionMode` without plans is a warning.
3056
3247
 
3057
3248
  ## Printable Manual Setup Guide
3058
3249
 
@@ -2,7 +2,7 @@ import { EnergyAppPermissionType } from "./energy-app-permission.type.cjs";
2
2
  import { EnergyAppModelFeatureEnum } from "./energy-app-model-feature.enum.cjs";
3
3
  import type { EnyoFileTranslation } from "./types/enyo-file.cjs";
4
4
  import type { EnyoOnboardingV2Guide } from "./types/enyo-onboarding-v2.cjs";
5
- import type { EnyoSubscriptionPaymentInterval, EnyoSubscriptionPrice, EnyoSubscriptionPricingModel } from "./types/enyo-subscription.cjs";
5
+ import type { EnyoSubscriptionMode, EnyoSubscriptionPaymentInterval, EnyoSubscriptionPrice, EnyoSubscriptionPricingModel } from "./types/enyo-subscription.cjs";
6
6
  export type EnergyAppPackageLanguage = 'de' | 'en';
7
7
  export declare enum EnergyAppPackageCategory {
8
8
  Inverter = "inverter",
@@ -336,8 +336,10 @@ export interface EnergyAppPackageSubscriptionPlan {
336
336
  /**
337
337
  * Whether the price is charged once for the package on the device
338
338
  * (`per-package`, the default) or once per unit (`per-unit`). For
339
- * `per-unit` the app reports the units — appliances, vehicles or custom
340
- * units — at runtime with {@link EnergyAppSubscriptions.setUnits}.
339
+ * `per-unit` the units are the appliances and vehicles the app marks as
340
+ * billed under this plan ({@link EnyoAppliance.billing},
341
+ * {@link EnyoVehiclePairResult.billing}), plus the custom units it reports
342
+ * with {@link EnergyAppSubscriptions.setUnits}.
341
343
  */
342
344
  pricingModel?: EnyoSubscriptionPricingModel;
343
345
  /**
@@ -890,6 +892,20 @@ export interface EnergyAppPackageDefinition {
890
892
  * Omit for packages that are free.
891
893
  */
892
894
  subscriptionPlans?: EnergyAppPackageSubscriptionPlan[];
895
+ /**
896
+ * Whether the app needs one of its plans to work at all.
897
+ *
898
+ * - `'required'`: without an active plan the app does nothing useful and
899
+ * reports {@link EnergyAppStateEnum.SubscriptionRequired}. enyo offers no
900
+ * free option, and cancelling warns that the app stops working.
901
+ * - `'optional'`: the app has a free base; its plans add features. The app
902
+ * never reports `SubscriptionRequired`. enyo offers a free base option
903
+ * and lets the customer downgrade to it.
904
+ *
905
+ * Required as soon as {@link subscriptionPlans} is set; omit it for free
906
+ * packages.
907
+ */
908
+ subscriptionMode?: EnyoSubscriptionMode;
893
909
  }
894
910
  /**
895
911
  * Defines an Energy App package with automatic SDK version injection.
@@ -9,6 +9,12 @@ var EnergyAppStateEnum;
9
9
  EnergyAppStateEnum["ConfigurationRequired"] = "configuration-required";
10
10
  /** This state tells the user that an internet connection of the system is required which blocks the energy app from running*/
11
11
  EnergyAppStateEnum["InternetConnectionRequired"] = "internet-connection-required";
12
- /** This state tells the user that a subscription plan is required which blocks the energy app from running. The host shows the plans declared in the package definition. */
12
+ /**
13
+ * This state tells the user that a subscription plan is required which blocks the energy app from running. The host shows the plans declared in the package definition.
14
+ *
15
+ * Only for packages with `subscriptionMode: 'required'`: such an app reports this state while it has no active
16
+ * plan, and returns to `Running` once one is active. An app with `subscriptionMode: 'optional'` must never report
17
+ * it — it keeps its free base running and gates only the paid features with `isPlanActive()` / `isUnitActive()`.
18
+ */
13
19
  EnergyAppStateEnum["SubscriptionRequired"] = "subscription-required";
14
20
  })(EnergyAppStateEnum || (exports.EnergyAppStateEnum = EnergyAppStateEnum = {}));
@@ -60,7 +60,13 @@ export declare enum EnergyAppStateEnum {
60
60
  ConfigurationRequired = "configuration-required",
61
61
  /** This state tells the user that an internet connection of the system is required which blocks the energy app from running*/
62
62
  InternetConnectionRequired = "internet-connection-required",
63
- /** This state tells the user that a subscription plan is required which blocks the energy app from running. The host shows the plans declared in the package definition. */
63
+ /**
64
+ * This state tells the user that a subscription plan is required which blocks the energy app from running. The host shows the plans declared in the package definition.
65
+ *
66
+ * Only for packages with `subscriptionMode: 'required'`: such an app reports this state while it has no active
67
+ * plan, and returns to `Running` once one is active. An app with `subscriptionMode: 'optional'` must never report
68
+ * it — it keeps its free base running and gates only the paid features with `isPlanActive()` / `isUnitActive()`.
69
+ */
64
70
  SubscriptionRequired = "subscription-required"
65
71
  }
66
72
  /**
@@ -11,7 +11,9 @@ const subscription_plan_validators_js_1 = require("./subscription-plan-validator
11
11
  * {@link upsertSubscription} simulates a purchase, renewal, cancellation,
12
12
  * failed payment or expiry and notifies the registered listeners exactly like
13
13
  * the host would. Units reported with {@link setUnits} are kept per plan and
14
- * can be read back with {@link getUnits}.
14
+ * can be read back with {@link getUnits}, together with the billed appliance
15
+ * and vehicle units a test sets with {@link setBilledUnits}. {@link assertEnergyAppState}
16
+ * checks a reported app state against the package's subscription mode.
15
17
  *
16
18
  * @example
17
19
  * ```typescript
@@ -34,52 +36,92 @@ const subscription_plan_validators_js_1 = require("./subscription-plan-validator
34
36
  */
35
37
  class InMemorySubscriptions {
36
38
  plans;
39
+ subscriptionMode;
37
40
  subscriptions = new Map();
38
41
  listeners = new Map();
39
- unitsByPlan = new Map();
42
+ customUnitsByPlan = new Map();
43
+ billedUnitsByPlan = new Map();
40
44
  nextListenerId = 1;
41
45
  /**
42
46
  * @param initialSubscriptions - Subscriptions that exist before the app
43
47
  * starts. They do not trigger listeners.
44
- * @param plans - Optional plans of the package. When given, {@link setUnits}
45
- * rejects plan keys that are not declared as `per-unit`, like the host
46
- * does. When omitted, every plan key is accepted.
48
+ * @param plans - Optional plans of the package. When given,
49
+ * {@link setUnits} and {@link setBilledUnits} reject plan keys that are
50
+ * not declared as `per-unit`, like the host does. When omitted, every
51
+ * plan key is accepted.
52
+ * @param subscriptionMode - Optional subscription mode of the package,
53
+ * checked by {@link assertEnergyAppState}.
47
54
  */
48
- constructor(initialSubscriptions = [], plans) {
55
+ constructor(initialSubscriptions = [], plans, subscriptionMode) {
49
56
  this.plans = plans;
57
+ this.subscriptionMode = subscriptionMode;
50
58
  for (const subscription of initialSubscriptions) {
51
59
  this.subscriptions.set(subscription.subscriptionId, structuredClone(subscription));
52
60
  }
53
61
  }
54
62
  /**
55
- * Stores the units reported for a `per-unit` plan, replacing earlier ones.
63
+ * Stores the custom units reported for a `per-unit` plan, replacing
64
+ * earlier ones.
56
65
  *
57
66
  * @param planKey - The key of the plan.
58
- * @param units - Every unit the app currently manages for the plan.
67
+ * @param units - Every custom unit the app currently manages for the plan.
59
68
  * @returns Promise that resolves once the units are stored
60
- * @throws {Error} If the units are invalid, or `plans` were given and
61
- * `planKey` is not a `per-unit` plan among them.
69
+ * @throws {Error} If a unit is not a custom unit or invalid, or `plans`
70
+ * were given and `planKey` is not a `per-unit` plan among them.
62
71
  */
63
72
  async setUnits(planKey, units) {
64
- if (this.plans) {
65
- const plan = this.plans.find(candidate => candidate.planKey === planKey);
66
- if (plan?.pricingModel !== enyo_subscription_js_1.EnyoSubscriptionPricingModelEnum.PerUnit) {
67
- throw new Error(`Plan "${planKey}" is not a per-unit plan of this package.`);
68
- }
73
+ this.assertPerUnitPlan(planKey);
74
+ const notCustom = units.filter(unit => unit.type !== enyo_subscription_js_1.EnyoSubscriptionUnitTypeEnum.Custom);
75
+ if (notCustom.length > 0) {
76
+ throw new Error(`setUnits() only takes custom units — mark appliances and vehicles with \`billing\` instead (got ${notCustom.map(unit => unit.type).join(', ')}).`);
69
77
  }
70
- const { ok, errors } = (0, subscription_plan_validators_js_1.validateSubscriptionUnits)(units);
71
- if (!ok)
72
- throw new Error(`Invalid subscription units:\n- ${errors.join('\n- ')}`);
73
- this.unitsByPlan.set(planKey, units.map(unit => structuredClone(unit)));
78
+ this.assertValidUnits(units);
79
+ this.customUnitsByPlan.set(planKey, units.map(unit => structuredClone(unit)));
80
+ }
81
+ /**
82
+ * Test helper: sets the appliance and vehicle units the host keeps for a
83
+ * plan from their billing marks, replacing earlier ones — as if the app
84
+ * had saved those appliances or paired those vehicles with
85
+ * `billing: {billed: true, planKey}`.
86
+ *
87
+ * @param planKey - The key of the plan.
88
+ * @param units - The billed appliance and vehicle units.
89
+ * @returns Promise that resolves once the units are stored
90
+ * @throws {Error} If a unit is a custom unit or invalid, or `plans` were
91
+ * given and `planKey` is not a `per-unit` plan among them.
92
+ */
93
+ async setBilledUnits(planKey, units) {
94
+ this.assertPerUnitPlan(planKey);
95
+ if (units.some(unit => unit.type === enyo_subscription_js_1.EnyoSubscriptionUnitTypeEnum.Custom)) {
96
+ throw new Error('setBilledUnits() only takes appliance and vehicle units — report custom units with setUnits().');
97
+ }
98
+ this.assertValidUnits(units);
99
+ this.billedUnitsByPlan.set(planKey, units.map(unit => structuredClone(unit)));
100
+ }
101
+ /**
102
+ * Test helper: checks an app state against the subscription mode given to
103
+ * the constructor. Call it wherever the app under test reports its state.
104
+ *
105
+ * @param state - The state the app reports.
106
+ * @throws {Error} When the package's subscription mode is `optional` and
107
+ * the state is `subscription-required`.
108
+ */
109
+ assertEnergyAppState(state) {
110
+ (0, subscription_plan_validators_js_1.assertEnergyAppStateMatchesSubscriptionMode)(this.subscriptionMode, state);
74
111
  }
75
112
  /**
76
- * Returns copies of the units last reported for a plan.
113
+ * Returns copies of the current units of a plan: the billed appliances and
114
+ * vehicles set with {@link setBilledUnits}, followed by the custom units
115
+ * reported with {@link setUnits}.
77
116
  *
78
117
  * @param planKey - The key of the plan.
79
118
  * @returns Promise that resolves to the units; an empty list when none were reported
80
119
  */
81
120
  async getUnits(planKey) {
82
- return (this.unitsByPlan.get(planKey) ?? []).map(unit => structuredClone(unit));
121
+ return [
122
+ ...(this.billedUnitsByPlan.get(planKey) ?? []),
123
+ ...(this.customUnitsByPlan.get(planKey) ?? []),
124
+ ].map(unit => structuredClone(unit));
83
125
  }
84
126
  /**
85
127
  * Returns copies of all stored subscriptions.
@@ -130,5 +172,29 @@ class InMemorySubscriptions {
130
172
  await listener({ subscription: structuredClone(subscription), previousStatus });
131
173
  }
132
174
  }
175
+ /**
176
+ * Throws when `plans` were given and `planKey` is not a `per-unit` plan
177
+ * among them.
178
+ *
179
+ * @param planKey - The key of the plan.
180
+ */
181
+ assertPerUnitPlan(planKey) {
182
+ if (!this.plans)
183
+ return;
184
+ const plan = this.plans.find(candidate => candidate.planKey === planKey);
185
+ if (plan?.pricingModel !== enyo_subscription_js_1.EnyoSubscriptionPricingModelEnum.PerUnit) {
186
+ throw new Error(`Plan "${planKey}" is not a per-unit plan of this package.`);
187
+ }
188
+ }
189
+ /**
190
+ * Throws when the units break the host's rules.
191
+ *
192
+ * @param units - The units to check.
193
+ */
194
+ assertValidUnits(units) {
195
+ const { ok, errors } = (0, subscription_plan_validators_js_1.validateSubscriptionUnits)(units);
196
+ if (!ok)
197
+ throw new Error(`Invalid subscription units:\n- ${errors.join('\n- ')}`);
198
+ }
133
199
  }
134
200
  exports.InMemorySubscriptions = InMemorySubscriptions;