@enyo-energy/energy-app-sdk 1.41.0 → 1.42.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 (28) hide show
  1. package/README.md +115 -7
  2. package/dist/cjs/energy-app-package-definition.cjs +51 -1
  3. package/dist/cjs/energy-app-package-definition.d.cts +172 -0
  4. package/dist/cjs/implementations/compatibility/compatibility-validators.cjs +253 -0
  5. package/dist/cjs/implementations/compatibility/compatibility-validators.d.cts +111 -0
  6. package/dist/cjs/implementations/installer-mail/define-installer-mail.cjs +89 -0
  7. package/dist/cjs/implementations/installer-mail/define-installer-mail.d.cts +80 -0
  8. package/dist/cjs/implementations/installer-mail/installer-mail-validators.cjs +251 -0
  9. package/dist/cjs/implementations/installer-mail/installer-mail-validators.d.cts +88 -0
  10. package/dist/cjs/index.cjs +3 -0
  11. package/dist/cjs/index.d.cts +3 -0
  12. package/dist/cjs/types/enyo-vehicle.d.cts +16 -0
  13. package/dist/cjs/version.cjs +1 -1
  14. package/dist/cjs/version.d.cts +1 -1
  15. package/dist/energy-app-package-definition.d.ts +172 -0
  16. package/dist/energy-app-package-definition.js +50 -0
  17. package/dist/implementations/compatibility/compatibility-validators.d.ts +111 -0
  18. package/dist/implementations/compatibility/compatibility-validators.js +246 -0
  19. package/dist/implementations/installer-mail/define-installer-mail.d.ts +80 -0
  20. package/dist/implementations/installer-mail/define-installer-mail.js +83 -0
  21. package/dist/implementations/installer-mail/installer-mail-validators.d.ts +88 -0
  22. package/dist/implementations/installer-mail/installer-mail-validators.js +244 -0
  23. package/dist/index.d.ts +3 -0
  24. package/dist/index.js +3 -0
  25. package/dist/types/enyo-vehicle.d.ts +16 -0
  26. package/dist/version.d.ts +1 -1
  27. package/dist/version.js +1 -1
  28. package/package.json +1 -1
package/README.md CHANGED
@@ -34,6 +34,8 @@ The official TypeScript SDK for building Energy Apps on the enyo platform. Creat
34
34
  - [Startup pattern](#startup-pattern)
35
35
  - [Package Files](#package-files)
36
36
  - [Printable Manual Setup Guide](#printable-manual-setup-guide)
37
+ - [Installer Mail](#installer-mail)
38
+ - [Compatibility Declaration](#compatibility-declaration)
37
39
  - [Firmware Update Registry](#firmware-update-registry)
38
40
  - [Declaring firmware](#declaring-firmware)
39
41
  - [Firmware modes](#firmware-modes)
@@ -994,23 +996,36 @@ Pairing needs the `VehicleIntegration` permission; reading vehicles needs only
994
996
 
995
997
  ##### Declaring supported brands
996
998
 
997
- Car integrations declare brands, not model lines — the vendor's cloud serves
998
- every car in the account and the line-up changes yearly. An empty `models`
999
- array with `default: true` reads as "every car of this brand":
999
+ Declare every brand the package serves. Where you know the model line-up, list the models
1000
+ with their **model years** — a model name alone is ambiguous, because the same name is sold
1001
+ across generations that talk to different APIs. Give each generation its own entry; the same
1002
+ `modelName` may appear more than once as long as the `modelYears` ranges do not overlap.
1003
+ Leave out `to` while a model is still built.
1000
1004
 
1001
1005
  ```typescript
1002
1006
  categories: [EnergyAppPackageCategory.Vehicle],
1003
1007
  compatibility: [{
1008
+ vendorName: 'Volkswagen',
1009
+ defaultEnergyApp: true,
1010
+ models: [
1011
+ {modelName: 'ID.3', modelYears: {from: 2020, to: 2023}, features: [EnergyAppModelFeatureEnum.VehicleSocReadout]},
1012
+ {modelName: 'ID.3', modelYears: {from: 2024}, features: [EnergyAppModelFeatureEnum.VehicleSocReadout]},
1013
+ ],
1014
+ }, {
1015
+ // Brand-wide support: an empty `models` array reads as "every car of this brand".
1004
1016
  vendorName: 'Tesla',
1005
- default: true,
1017
+ defaultEnergyApp: true,
1006
1018
  models: [],
1007
1019
  }],
1008
1020
  ```
1009
1021
 
1010
- Declare per-model entries only where support genuinely differs, and set
1011
- `features` honestly — a brand whose API only reports SoC
1022
+ When the user picked a model and year in the enyo app, the pair handler receives them as
1023
+ `context.modelName` and `context.modelYear` next to `context.vendorName`.
1024
+
1025
+ Set `features` honestly — a brand whose API only reports SoC
1012
1026
  (`VehicleSocReadout`) should not look like one that can also start a charge
1013
- (`VehicleChargeStartStop`). See `EnergyAppModelFeatureEnum`'s vehicle group.
1027
+ (`VehicleChargeStartStop`). See `EnergyAppModelFeatureEnum`'s vehicle group, and validate
1028
+ the list with `validatePackageCompatibility()` (see [Compatibility Declaration](#compatibility-declaration)).
1014
1029
 
1015
1030
  ##### Charge modes
1016
1031
 
@@ -3297,6 +3312,99 @@ const {ok, errors, warnings} = validatePackageOnboardingGuide(packageDef);
3297
3312
  The complete authoring guide, with the reason behind each rule and a full example, is the
3298
3313
  JSDoc of `EnergyAppPackageDefinition.onboardingGuide`.
3299
3314
 
3315
+ ## Installer Mail
3316
+
3317
+ A package can declare the mail a customer sends to their installer from the enyo customer
3318
+ portal, telling the installer what to do. The enyo CLI reads it during `enyo release`. In the
3319
+ portal the customer enters the installer's address and details only they know, such as the
3320
+ device's serial number; enyo fills in the placeholders, renders the Markdown body as an HTML
3321
+ mail and sends it — with the PDF of the [Printable Manual Setup Guide](#printable-manual-setup-guide)
3322
+ attached when the package declares one (`attachOnboardingGuide: false` turns that off).
3323
+
3324
+ - `subject` is plain text on one line, `body` is Markdown (raw HTML is not rendered); both
3325
+ need `de` and `en`.
3326
+ - Placeholders are written as `{{name}}`; `installerMailPlaceholder()` writes them for you.
3327
+ The list is closed (`EnyoInstallerMailPlaceholder`): `customerName`, `customerNumber`,
3328
+ `customerEmail`, `customerPhone`, `installationStreet`, `installationZip`,
3329
+ `installationCity`, `serialNumber` (entered by the customer), `vendorName`, `modelName`,
3330
+ `energyAppName`, `onboardingGuideUrl`.
3331
+ - Any value can be missing when the mail is sent and is then left empty — put placeholders in
3332
+ lists of details rather than in the middle of a sentence.
3333
+ - `attachments` names further files declared in `files`.
3334
+
3335
+ ```typescript
3336
+ import {
3337
+ defineEnergyAppPackage,
3338
+ defineInstallerMail,
3339
+ EnyoInstallerMailPlaceholder as P,
3340
+ installerMailPlaceholder as p,
3341
+ renderInstallerMail,
3342
+ validatePackageInstallerMail
3343
+ } from '@enyo-energy/energy-app-sdk';
3344
+
3345
+ const packageDef = defineEnergyAppPackage({
3346
+ // ...
3347
+ onboardingGuide: manualSetupGuide,
3348
+ onboardingFlowInstallerMail: defineInstallerMail({
3349
+ subject: [
3350
+ {language: 'de', value: `Einrichtung ${p(P.EnergyAppName)} bei ${p(P.CustomerName)}`},
3351
+ {language: 'en', value: `${p(P.EnergyAppName)} setup for ${p(P.CustomerName)}`}
3352
+ ],
3353
+ body: [
3354
+ {language: 'de', value: [
3355
+ 'Bitte aktiviere Modbus TCP am Wechselrichter, damit enyo ihn steuern kann.',
3356
+ '',
3357
+ `- **Kunde:** ${p(P.CustomerName)} (${p(P.CustomerNumber)})`,
3358
+ `- **Adresse:** ${p(P.InstallationStreet)}, ${p(P.InstallationZip)} ${p(P.InstallationCity)}`,
3359
+ `- **Gerät:** ${p(P.VendorName)} ${p(P.ModelName)}, Seriennummer ${p(P.SerialNumber)}`,
3360
+ '',
3361
+ `Die Schritt-für-Schritt-Anleitung findest du [hier](${p(P.OnboardingGuideUrl)}).`
3362
+ ].join('\n')},
3363
+ {language: 'en', value: '...'}
3364
+ ]
3365
+ })
3366
+ });
3367
+
3368
+ const {ok, errors, warnings} = validatePackageInstallerMail(packageDef);
3369
+
3370
+ // Preview what the installer receives:
3371
+ const preview = renderInstallerMail(packageDef.onboardingFlowInstallerMail!, 'de', {
3372
+ [P.CustomerName]: 'Erika Mustermann',
3373
+ [P.SerialNumber]: 'SN-123'
3374
+ });
3375
+ ```
3376
+
3377
+ Unknown placeholders, unpaired `{{` / `}}`, a missing language, a multi-line subject,
3378
+ `{{onboardingGuideUrl}}` or `attachOnboardingGuide: true` without an `onboardingGuide`, and
3379
+ attachments not declared in `files` are errors; raw HTML in the body and different
3380
+ placeholders per language are warnings.
3381
+
3382
+ ## Compatibility Declaration
3383
+
3384
+ `compatibility` is the list of vendors and models enyo shows customers in the enyo Store,
3385
+ the onboarding flows and the customer portal. Each model may carry `modelYears`
3386
+ (`{from?, to?}`, both inclusive) — use it whenever a model name spans several generations,
3387
+ for vehicles as well as for inverters, wallboxes or heat pumps.
3388
+
3389
+ ```typescript
3390
+ import {isYearInCompatibilityRange, validatePackageCompatibility} from '@enyo-energy/energy-app-sdk';
3391
+
3392
+ compatibility: [{
3393
+ vendorName: 'SolarEdge',
3394
+ models: [
3395
+ {modelName: 'SE10K', modelYears: {from: 2016, to: 2020}, features: []},
3396
+ {modelName: 'SE10K-RW0TEBNN4', modelYears: {from: 2021}, features: []}
3397
+ ]
3398
+ }]
3399
+
3400
+ const {ok, errors, warnings} = validatePackageCompatibility(packageDef);
3401
+ isYearInCompatibilityRange({from: 2021}, 2023); // true
3402
+ ```
3403
+
3404
+ Duplicate vendors, the same model name for overlapping years, `from` after `to` and years
3405
+ outside 1990 to two years ahead are errors. An empty range, and a vehicle model without
3406
+ `modelYears`, are warnings.
3407
+
3300
3408
  ## Firmware Update Registry
3301
3409
 
3302
3410
  Ship firmware images with your package and hand them to devices at runtime. You declare the files by local path in the package definition, the enyo CLI uploads them during `enyo release`, and the app reaches them through `energyApp.useFirmwareRegistry()`. The release tarball never carries the bytes.
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.EnergyAppPackageFirmwareModeEnum = exports.EnergyAppPackageCompatibilityStatus = exports.EnergyAppPackageOptionsDeviceDetectionModbusModeEnum = exports.EnergyAppPackageCategory = void 0;
3
+ exports.EnyoInstallerMailPlaceholder = exports.EnergyAppPackageFirmwareModeEnum = exports.EnergyAppPackageCompatibilityStatus = exports.EnergyAppPackageOptionsDeviceDetectionModbusModeEnum = exports.EnergyAppPackageCategory = void 0;
4
4
  exports.defineEnergyAppPackage = defineEnergyAppPackage;
5
5
  const version_js_1 = require("./version.cjs");
6
6
  var EnergyAppPackageCategory;
@@ -84,6 +84,56 @@ var EnergyAppPackageFirmwareModeEnum;
84
84
  /** Follow the explicit `installForFirmwareVersion` upgrade graph. */
85
85
  EnergyAppPackageFirmwareModeEnum["Dependent"] = "dependent";
86
86
  })(EnergyAppPackageFirmwareModeEnum || (exports.EnergyAppPackageFirmwareModeEnum = EnergyAppPackageFirmwareModeEnum = {}));
87
+ /**
88
+ * The values enyo can insert into an
89
+ * {@link EnergyAppPackageInstallerMail installer mail}.
90
+ *
91
+ * Write a placeholder into the subject or body as `{{name}}` — for example
92
+ * `{{customerName}}` — or build it with `installerMailPlaceholder()`. The list
93
+ * is closed: the mail is sent by enyo while the app is not involved, so every
94
+ * value has to come from enyo, and `validatePackageInstallerMail()` rejects
95
+ * any name not listed here.
96
+ *
97
+ * Any value can be missing when the mail is sent (a customer without a phone
98
+ * number, a serial number the customer skipped). enyo then inserts an empty
99
+ * string, so write sentences that still read well without the value — put
100
+ * placeholders in lists of details rather than in the middle of a sentence.
101
+ */
102
+ var EnyoInstallerMailPlaceholder;
103
+ (function (EnyoInstallerMailPlaceholder) {
104
+ /** Full name of the customer. */
105
+ EnyoInstallerMailPlaceholder["CustomerName"] = "customerName";
106
+ /** The customer's enyo customer number. */
107
+ EnyoInstallerMailPlaceholder["CustomerNumber"] = "customerNumber";
108
+ /** The customer's e-mail address — where the installer can reply. */
109
+ EnyoInstallerMailPlaceholder["CustomerEmail"] = "customerEmail";
110
+ /** The customer's phone number. */
111
+ EnyoInstallerMailPlaceholder["CustomerPhone"] = "customerPhone";
112
+ /** Street and house number of the installation site. */
113
+ EnyoInstallerMailPlaceholder["InstallationStreet"] = "installationStreet";
114
+ /** Postal code of the installation site. */
115
+ EnyoInstallerMailPlaceholder["InstallationZip"] = "installationZip";
116
+ /** City of the installation site. */
117
+ EnyoInstallerMailPlaceholder["InstallationCity"] = "installationCity";
118
+ /**
119
+ * Serial number of the device to set up (e.g. the inverter), as the
120
+ * customer entered it in the portal. enyo does not know it from anywhere
121
+ * else, so it is the value most likely to be missing.
122
+ */
123
+ EnyoInstallerMailPlaceholder["SerialNumber"] = "serialNumber";
124
+ /** Vendor the customer selected, one of the package's `compatibility` vendors. */
125
+ EnyoInstallerMailPlaceholder["VendorName"] = "vendorName";
126
+ /** Model the customer selected, one of that vendor's `compatibility` models. */
127
+ EnyoInstallerMailPlaceholder["ModelName"] = "modelName";
128
+ /** Display title of the Energy App from its store entry, in the mail's language. */
129
+ EnyoInstallerMailPlaceholder["EnergyAppName"] = "energyAppName";
130
+ /**
131
+ * Public link to the PDF rendered from
132
+ * {@link EnergyAppPackageDefinition.onboardingGuide}. Only allowed when the
133
+ * package declares that guide.
134
+ */
135
+ EnyoInstallerMailPlaceholder["OnboardingGuideUrl"] = "onboardingGuideUrl";
136
+ })(EnyoInstallerMailPlaceholder || (exports.EnyoInstallerMailPlaceholder = EnyoInstallerMailPlaceholder = {}));
87
137
  /**
88
138
  * Defines an Energy App package with automatic SDK version injection.
89
139
  * This function automatically adds the current SDK version to the package definition
@@ -2,6 +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 { EnyoOnboardingTranslatedContent } from "./types/enyo-onboarding.cjs";
5
6
  import type { EnyoSubscriptionMode, EnyoSubscriptionPaymentInterval, EnyoSubscriptionPrice, EnyoSubscriptionPricingModel } from "./types/enyo-subscription.cjs";
6
7
  export type EnergyAppPackageLanguage = 'de' | 'en';
7
8
  export declare enum EnergyAppPackageCategory {
@@ -387,6 +388,38 @@ export declare enum EnergyAppPackageCompatibilityStatus {
387
388
  /** The package has been verified **not** to work with this vendor or model. */
388
389
  NotCompatible = "not-compatible"
389
390
  }
391
+ /**
392
+ * The model years a {@link EnergyAppPackageCompatibilityModel} entry covers,
393
+ * both ends inclusive.
394
+ *
395
+ * Model names alone are ambiguous for products that are sold under one name
396
+ * for many years: a 2020 "ID.3" and a 2024 "ID.3" can talk to completely
397
+ * different vendor APIs, and the same holds for inverter generations. The range
398
+ * lets the enyo Store, onboarding flows and the customer portal tell a user
399
+ * whether *their* device is covered.
400
+ *
401
+ * Years are whole calendar years (model years for vehicles, years of
402
+ * manufacture for other devices). Leave out {@link to} for a model that is
403
+ * still being built; leave out {@link from} only when the start is genuinely
404
+ * unknown. A product with several generations is declared as several model
405
+ * entries, one range each.
406
+ *
407
+ * Validated by `validatePackageCompatibility()`.
408
+ *
409
+ * @example
410
+ * ```typescript
411
+ * models: [
412
+ * {modelName: 'ID.3', modelYears: {from: 2020, to: 2023}, features: [...]},
413
+ * {modelName: 'ID.3', modelYears: {from: 2024}, features: [...]},
414
+ * ]
415
+ * ```
416
+ */
417
+ export interface EnergyAppPackageCompatibilityYearRange {
418
+ /** First supported year, inclusive. Omit when the start is unknown. */
419
+ from?: number;
420
+ /** Last supported year, inclusive. Omit while the model is still built. */
421
+ to?: number;
422
+ }
390
423
  /**
391
424
  * A specific device model supported by an Energy App package.
392
425
  * the concrete models the package has been verified to work with.
@@ -394,6 +427,16 @@ export declare enum EnergyAppPackageCompatibilityStatus {
394
427
  export interface EnergyAppPackageCompatibilityModel {
395
428
  /** Human-readable model name as marketed by the vendor (e.g. "SE10K-RW0TEBNN4") */
396
429
  modelName: string;
430
+ /**
431
+ * Optional range of model years this entry covers.
432
+ *
433
+ * Recommended for every model whose name is reused across generations —
434
+ * always for vehicles, and for inverters, wallboxes or heat pumps where a
435
+ * name spans hardware revisions. The same `modelName` may appear more than
436
+ * once per vendor as long as the ranges do not overlap. Omit when the entry
437
+ * applies to every year the model was built.
438
+ */
439
+ modelYears?: EnergyAppPackageCompatibilityYearRange;
397
440
  /**
398
441
  * Optional internal display name shown in the enyo Store / Admin UI when
399
442
  * different from the official `modelName` (e.g. a friendlier label).
@@ -669,6 +712,117 @@ export interface EnergyAppPackageFirmwareFile {
669
712
  /** Optional internal note explaining this image; never shown to users. */
670
713
  internalComment?: string;
671
714
  }
715
+ /**
716
+ * The values enyo can insert into an
717
+ * {@link EnergyAppPackageInstallerMail installer mail}.
718
+ *
719
+ * Write a placeholder into the subject or body as `{{name}}` — for example
720
+ * `{{customerName}}` — or build it with `installerMailPlaceholder()`. The list
721
+ * is closed: the mail is sent by enyo while the app is not involved, so every
722
+ * value has to come from enyo, and `validatePackageInstallerMail()` rejects
723
+ * any name not listed here.
724
+ *
725
+ * Any value can be missing when the mail is sent (a customer without a phone
726
+ * number, a serial number the customer skipped). enyo then inserts an empty
727
+ * string, so write sentences that still read well without the value — put
728
+ * placeholders in lists of details rather than in the middle of a sentence.
729
+ */
730
+ export declare enum EnyoInstallerMailPlaceholder {
731
+ /** Full name of the customer. */
732
+ CustomerName = "customerName",
733
+ /** The customer's enyo customer number. */
734
+ CustomerNumber = "customerNumber",
735
+ /** The customer's e-mail address — where the installer can reply. */
736
+ CustomerEmail = "customerEmail",
737
+ /** The customer's phone number. */
738
+ CustomerPhone = "customerPhone",
739
+ /** Street and house number of the installation site. */
740
+ InstallationStreet = "installationStreet",
741
+ /** Postal code of the installation site. */
742
+ InstallationZip = "installationZip",
743
+ /** City of the installation site. */
744
+ InstallationCity = "installationCity",
745
+ /**
746
+ * Serial number of the device to set up (e.g. the inverter), as the
747
+ * customer entered it in the portal. enyo does not know it from anywhere
748
+ * else, so it is the value most likely to be missing.
749
+ */
750
+ SerialNumber = "serialNumber",
751
+ /** Vendor the customer selected, one of the package's `compatibility` vendors. */
752
+ VendorName = "vendorName",
753
+ /** Model the customer selected, one of that vendor's `compatibility` models. */
754
+ ModelName = "modelName",
755
+ /** Display title of the Energy App from its store entry, in the mail's language. */
756
+ EnergyAppName = "energyAppName",
757
+ /**
758
+ * Public link to the PDF rendered from
759
+ * {@link EnergyAppPackageDefinition.onboardingGuide}. Only allowed when the
760
+ * package declares that guide.
761
+ */
762
+ OnboardingGuideUrl = "onboardingGuideUrl"
763
+ }
764
+ /**
765
+ * A mail the customer sends to their installer from the enyo customer portal,
766
+ * declared in {@link EnergyAppPackageDefinition.onboardingFlowInstallerMail}.
767
+ *
768
+ * ## Format
769
+ *
770
+ * - {@link subject} is plain text on one line.
771
+ * - {@link body} is Markdown (CommonMark: paragraphs, headings, emphasis,
772
+ * lists, links). enyo renders it into its HTML mail template, so do not add a
773
+ * greeting header, footer or styling of your own. Raw HTML is not rendered.
774
+ * - Both may contain {@link EnyoInstallerMailPlaceholder placeholders} written
775
+ * as `{{name}}`. enyo inserts the values as text, escaped for Markdown, so a
776
+ * customer name cannot break the layout.
777
+ * - Both need a `de` and an `en` entry; enyo picks the one matching the
778
+ * customer's language.
779
+ *
780
+ * @example
781
+ * ```typescript
782
+ * const p = installerMailPlaceholder;
783
+ * const P = EnyoInstallerMailPlaceholder;
784
+ *
785
+ * onboardingFlowInstallerMail: defineInstallerMail({
786
+ * subject: [
787
+ * {language: 'de', value: `Einrichtung ${p(P.EnergyAppName)} für ${p(P.CustomerName)}`},
788
+ * {language: 'en', value: `${p(P.EnergyAppName)} setup for ${p(P.CustomerName)}`},
789
+ * ],
790
+ * body: [
791
+ * {language: 'de', value: [
792
+ * 'Bitte aktiviere Modbus TCP am Wechselrichter, damit enyo ihn steuern kann.',
793
+ * '',
794
+ * `- Kunde: ${p(P.CustomerName)} (${p(P.CustomerNumber)})`,
795
+ * `- Seriennummer: ${p(P.SerialNumber)}`,
796
+ * '',
797
+ * `[Anleitung (PDF)](${p(P.OnboardingGuideUrl)})`,
798
+ * ].join('\n')},
799
+ * {language: 'en', value: '...'},
800
+ * ],
801
+ * })
802
+ * ```
803
+ */
804
+ export interface EnergyAppPackageInstallerMail {
805
+ /** Translated subject line: plain text, one line, may contain placeholders. */
806
+ subject: EnyoOnboardingTranslatedContent[];
807
+ /** Translated Markdown body, may contain placeholders. */
808
+ body: EnyoOnboardingTranslatedContent[];
809
+ /**
810
+ * Whether enyo attaches the PDF rendered from
811
+ * {@link EnergyAppPackageDefinition.onboardingGuide}. Defaults to `true`
812
+ * when the package declares that guide. Setting it to `true` without a
813
+ * guide is a validation error.
814
+ */
815
+ attachOnboardingGuide?: boolean;
816
+ /**
817
+ * Further attachments, by the `name` of a file declared in
818
+ * {@link EnergyAppPackageDefinition.files} (a datasheet, a wiring diagram).
819
+ * Those files are public anyway; attaching them only saves the installer
820
+ * a download.
821
+ */
822
+ attachments?: string[];
823
+ /** Optional internal note explaining this mail; never shown to anyone. */
824
+ internalComment?: string;
825
+ }
672
826
  /**
673
827
  * Complete definition for a enyo Energy App package.
674
828
  * This interface defines all the metadata, permissions, and configuration
@@ -879,6 +1033,24 @@ export interface EnergyAppPackageDefinition {
879
1033
  * Omit the field for packages that have no manual setup to document.
880
1034
  */
881
1035
  onboardingGuide?: EnyoOnboardingV2Guide;
1036
+ /**
1037
+ * The mail a customer sends to their installer from the enyo customer
1038
+ * portal, telling the installer what to do to set the device up.
1039
+ *
1040
+ * The enyo CLI reads it at release time. In the portal the customer enters
1041
+ * the installer's address and the details only they know (such as the
1042
+ * device's serial number); enyo fills in the
1043
+ * {@link EnyoInstallerMailPlaceholder placeholders}, renders the Markdown
1044
+ * body as an HTML mail and sends it — attaching the PDF of
1045
+ * {@link onboardingGuide} unless told otherwise.
1046
+ *
1047
+ * Validate it with `validatePackageInstallerMail()` before publishing; the
1048
+ * CLI refuses the release on errors. See
1049
+ * {@link EnergyAppPackageInstallerMail} for the format.
1050
+ *
1051
+ * Omit for packages that need no installer.
1052
+ */
1053
+ onboardingFlowInstallerMail?: EnergyAppPackageInstallerMail;
882
1054
  /**
883
1055
  * Subscription plans this package sells, including their prices. The enyo
884
1056
  * CLI reads them at release time; enyo offers them to the customer and
@@ -0,0 +1,253 @@
1
+ "use strict";
2
+ /**
3
+ * Client-side validator for the vendors and models a package declares in
4
+ * {@link EnergyAppPackageDefinition.compatibility}.
5
+ *
6
+ * enyo publishes this list to customers — in the enyo Store, the onboarding
7
+ * flows and the customer portal — as the answer to "does this app work with my
8
+ * device?". A duplicate vendor, an inverted year range or a model that appears
9
+ * twice for the same years makes that answer ambiguous, so those are blocking
10
+ * `errors`. `warnings` point at entries that are valid but probably less
11
+ * precise than intended, such as a car model declared without its model years.
12
+ *
13
+ * Use {@link validatePackageCompatibility} for the non-throwing result, or
14
+ * {@link assertValidPackageCompatibility} to throw on failure.
15
+ */
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.PackageCompatibilityValidationError = exports.COMPATIBILITY_MAX_YEARS_AHEAD = exports.COMPATIBILITY_MIN_YEAR = void 0;
18
+ exports.validatePackageCompatibility = validatePackageCompatibility;
19
+ exports.assertValidPackageCompatibility = assertValidPackageCompatibility;
20
+ exports.isYearInCompatibilityRange = isYearInCompatibilityRange;
21
+ const energy_app_package_definition_js_1 = require("../../energy-app-package-definition.cjs");
22
+ /**
23
+ * Earliest year accepted in a {@link EnergyAppPackageCompatibilityYearRange}.
24
+ * Nothing older can be integrated, so an earlier year is almost certainly a
25
+ * typo.
26
+ */
27
+ exports.COMPATIBILITY_MIN_YEAR = 1990;
28
+ /**
29
+ * How many years past the current one a range may reach. Vehicle model years
30
+ * run ahead of the calendar, and announced models are declared before release.
31
+ */
32
+ exports.COMPATIBILITY_MAX_YEARS_AHEAD = 2;
33
+ /**
34
+ * Thrown by {@link assertValidPackageCompatibility} when a package's
35
+ * compatibility declaration fails validation. The message lists every blocking
36
+ * error so callers can surface them directly.
37
+ */
38
+ class PackageCompatibilityValidationError extends Error {
39
+ /** The individual blocking errors that caused the failure. */
40
+ errors;
41
+ /**
42
+ * @param errors - The blocking validation errors.
43
+ */
44
+ constructor(errors) {
45
+ super(`Invalid package compatibility:\n- ${errors.join('\n- ')}`);
46
+ this.name = 'PackageCompatibilityValidationError';
47
+ this.errors = errors;
48
+ }
49
+ }
50
+ exports.PackageCompatibilityValidationError = PackageCompatibilityValidationError;
51
+ /**
52
+ * Validates a package's declared vendors and models: names present and unique,
53
+ * year ranges well-formed and plausible, and no model declared twice for
54
+ * overlapping years.
55
+ *
56
+ * Model names are compared case-insensitively, since a customer cannot tell
57
+ * "ID.3" from "id.3" apart. The same model name may appear more than once for
58
+ * a vendor — one entry per generation — as long as the
59
+ * {@link EnergyAppPackageCompatibilityModel.modelYears} ranges do not overlap.
60
+ *
61
+ * @param definition - The package definition, or just its `compatibility` and
62
+ * (optionally) `categories`.
63
+ * @param options - See {@link PackageCompatibilityValidationOptions}.
64
+ * @returns The {@link PackageCompatibilityValidationResult}.
65
+ *
66
+ * @example
67
+ * ```typescript
68
+ * const {ok, errors, warnings} = validatePackageCompatibility(packageDef);
69
+ * if (!ok) throw new Error(errors.join('\n'));
70
+ * ```
71
+ */
72
+ function validatePackageCompatibility(definition, options = {}) {
73
+ const errors = [];
74
+ const warnings = [];
75
+ const compatibility = definition.compatibility;
76
+ if (!Array.isArray(compatibility)) {
77
+ errors.push('`compatibility` must be an array of vendors.');
78
+ return { ok: false, errors, warnings };
79
+ }
80
+ const maxYear = (options.now ?? new Date()).getFullYear() + exports.COMPATIBILITY_MAX_YEARS_AHEAD;
81
+ const isVehiclePackage = definition.categories?.includes(energy_app_package_definition_js_1.EnergyAppPackageCategory.Vehicle) ?? false;
82
+ const vendorNames = new Map();
83
+ compatibility.forEach((vendor, vendorIndex) => {
84
+ const at = `compatibility[${vendorIndex}] (${vendor?.vendorName || '?'})`;
85
+ if (typeof vendor?.vendorName !== 'string' || vendor.vendorName.trim() === '') {
86
+ errors.push(`${at}: vendorName must be a non-empty string.`);
87
+ }
88
+ else {
89
+ const key = normalizeName(vendor.vendorName);
90
+ const first = vendorNames.get(key);
91
+ if (first !== undefined) {
92
+ errors.push(`${at}: duplicate vendor — already declared at compatibility[${first}]. Merge the model lists.`);
93
+ }
94
+ else {
95
+ vendorNames.set(key, vendorIndex);
96
+ }
97
+ }
98
+ if (!Array.isArray(vendor?.models)) {
99
+ errors.push(`${at}: models must be an array (empty for brand-wide support).`);
100
+ return;
101
+ }
102
+ validateModels(vendor, at, isVehiclePackage, maxYear, errors, warnings);
103
+ });
104
+ return { ok: errors.length === 0, errors, warnings };
105
+ }
106
+ /**
107
+ * Like {@link validatePackageCompatibility}, but throws
108
+ * {@link PackageCompatibilityValidationError} when there are blocking errors.
109
+ * Warnings never throw.
110
+ *
111
+ * @param definition - The package definition, or just its `compatibility` and
112
+ * (optionally) `categories`.
113
+ * @param options - See {@link PackageCompatibilityValidationOptions}.
114
+ * @returns The declared compatibility list.
115
+ * @throws {PackageCompatibilityValidationError} When validation produces any error.
116
+ */
117
+ function assertValidPackageCompatibility(definition, options = {}) {
118
+ const { ok, errors } = validatePackageCompatibility(definition, options);
119
+ if (!ok)
120
+ throw new PackageCompatibilityValidationError(errors);
121
+ return definition.compatibility;
122
+ }
123
+ /**
124
+ * Checks whether a year falls within a declared range. An open end matches
125
+ * every year on that side, and a missing range matches every year.
126
+ *
127
+ * This is the matching rule enyo applies when a customer picks a model year,
128
+ * exposed so an app can test its own declarations against real devices.
129
+ *
130
+ * @param range - The model's declared range, or `undefined` for "all years".
131
+ * @param year - The year to check.
132
+ * @returns `true` when `year` lies within `range`.
133
+ *
134
+ * @example
135
+ * ```typescript
136
+ * isYearInCompatibilityRange({from: 2020, to: 2023}, 2022); // true
137
+ * isYearInCompatibilityRange({from: 2024}, 2022); // false
138
+ * ```
139
+ */
140
+ function isYearInCompatibilityRange(range, year) {
141
+ if (!range)
142
+ return true;
143
+ if (range.from !== undefined && year < range.from)
144
+ return false;
145
+ if (range.to !== undefined && year > range.to)
146
+ return false;
147
+ return true;
148
+ }
149
+ /**
150
+ * Validates the models of one vendor: names, year ranges, and overlapping
151
+ * duplicates.
152
+ *
153
+ * @param vendor - The vendor whose models are checked.
154
+ * @param vendorAt - Human-readable location of the vendor, used in messages.
155
+ * @param isVehiclePackage - Whether the package is a vehicle integration.
156
+ * @param maxYear - The latest year a range may reach.
157
+ * @param errors - Collector for blocking problems.
158
+ * @param warnings - Collector for advisory problems.
159
+ */
160
+ function validateModels(vendor, vendorAt, isVehiclePackage, maxYear, errors, warnings) {
161
+ const seen = new Map();
162
+ vendor.models.forEach((model, modelIndex) => {
163
+ const at = `${vendorAt}.models[${modelIndex}] (${model?.modelName || '?'})`;
164
+ if (typeof model?.modelName !== 'string' || model.modelName.trim() === '') {
165
+ errors.push(`${at}: modelName must be a non-empty string.`);
166
+ return;
167
+ }
168
+ const rangeOk = validateYearRange(model.modelYears, at, maxYear, errors, warnings);
169
+ const isVehicleModel = model.category === energy_app_package_definition_js_1.EnergyAppPackageCategory.Vehicle ||
170
+ (model.category === undefined && isVehiclePackage);
171
+ if (isVehicleModel && model.modelYears === undefined) {
172
+ warnings.push(`${at}: no modelYears — a car model name usually spans several generations; declare which years are supported.`);
173
+ }
174
+ const key = normalizeName(model.modelName);
175
+ const earlier = seen.get(key) ?? [];
176
+ if (rangeOk) {
177
+ for (const other of earlier) {
178
+ if (rangesOverlap(other.range, model.modelYears)) {
179
+ errors.push(`${at}: duplicate model — ${vendorAt}.models[${other.index}] has the same name for ` +
180
+ 'overlapping years. Give each generation its own, non-overlapping modelYears.');
181
+ break;
182
+ }
183
+ }
184
+ earlier.push({ index: modelIndex, range: model.modelYears });
185
+ seen.set(key, earlier);
186
+ }
187
+ });
188
+ }
189
+ /**
190
+ * Validates one {@link EnergyAppPackageCompatibilityYearRange}.
191
+ *
192
+ * @param range - The declared range, possibly `undefined`.
193
+ * @param at - Human-readable location of the model, used in messages.
194
+ * @param maxYear - The latest year a range may reach.
195
+ * @param errors - Collector for blocking problems.
196
+ * @param warnings - Collector for advisory problems.
197
+ * @returns `true` when the range is absent or well-formed, so it can be
198
+ * compared against other ranges.
199
+ */
200
+ function validateYearRange(range, at, maxYear, errors, warnings) {
201
+ if (range === undefined)
202
+ return true;
203
+ if (range === null || typeof range !== 'object' || Array.isArray(range)) {
204
+ errors.push(`${at}: modelYears must be an object like {from: 2020, to: 2023}.`);
205
+ return false;
206
+ }
207
+ let ok = true;
208
+ for (const end of ['from', 'to']) {
209
+ const year = range[end];
210
+ if (year === undefined)
211
+ continue;
212
+ if (!Number.isInteger(year)) {
213
+ errors.push(`${at}: modelYears.${end} must be a whole year, got ${String(year)}.`);
214
+ ok = false;
215
+ }
216
+ else if (year < exports.COMPATIBILITY_MIN_YEAR || year > maxYear) {
217
+ errors.push(`${at}: modelYears.${end} ${year} is outside ${exports.COMPATIBILITY_MIN_YEAR}–${maxYear}.`);
218
+ ok = false;
219
+ }
220
+ }
221
+ if (ok && range.from !== undefined && range.to !== undefined && range.from > range.to) {
222
+ errors.push(`${at}: modelYears.from (${range.from}) is after modelYears.to (${range.to}).`);
223
+ ok = false;
224
+ }
225
+ if (ok && range.from === undefined && range.to === undefined) {
226
+ warnings.push(`${at}: modelYears is empty — omit it when the entry covers every year.`);
227
+ }
228
+ return ok;
229
+ }
230
+ /**
231
+ * Whether two year ranges share at least one year. A missing range or a
232
+ * missing end is unbounded on that side.
233
+ *
234
+ * @param a - The first range.
235
+ * @param b - The second range.
236
+ * @returns `true` when the ranges overlap.
237
+ */
238
+ function rangesOverlap(a, b) {
239
+ const aFrom = a?.from ?? -Infinity;
240
+ const aTo = a?.to ?? Infinity;
241
+ const bFrom = b?.from ?? -Infinity;
242
+ const bTo = b?.to ?? Infinity;
243
+ return aFrom <= bTo && bFrom <= aTo;
244
+ }
245
+ /**
246
+ * Normalizes a vendor or model name for duplicate detection.
247
+ *
248
+ * @param name - The declared name.
249
+ * @returns The trimmed, lower-cased name with inner whitespace collapsed.
250
+ */
251
+ function normalizeName(name) {
252
+ return name.trim().replace(/\s+/g, ' ').toLowerCase();
253
+ }