@enyo-energy/energy-app-sdk 1.38.0 → 1.40.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.
package/README.md CHANGED
@@ -1290,32 +1290,150 @@ prices.onPriceChange((newPrice) => {
1290
1290
 
1291
1291
  #### `useNotification(): EnergyAppNotification`
1292
1292
 
1293
- 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.
1294
1295
 
1295
1296
  ```typescript
1296
1297
  const notifications = energyApp.useNotification();
1297
1298
 
1298
- // Send info notification
1299
- await notifications.sendNotification({
1300
- type: 'info',
1301
- title: 'Energy Optimization',
1302
- message: 'Your system is running optimally',
1303
- timestamp: Date.now()
1299
+ notifications.registerNotificationCategory({
1300
+ category: 'connection-issue',
1301
+ name: [
1302
+ { language: 'en', name: 'Connection issue' },
1303
+ { language: 'de', name: 'Verbindungsproblem' }
1304
+ ]
1304
1305
  });
1305
1306
 
1306
- // Send warning
1307
- await notifications.sendNotification({
1308
- type: 'warning',
1309
- title: 'High Energy Consumption',
1310
- message: 'Consider reducing energy usage during peak hours'
1311
- });
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. |
1312
1337
 
1313
- // Send critical alert
1314
- await notifications.sendNotification({
1315
- type: 'error',
1316
- title: 'System Fault',
1317
- message: 'Inverter communication lost - please check connection'
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();
1360
+
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
+ ],
1318
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
+ );
1319
1437
  ```
1320
1438
 
1321
1439
  ### App Intelligence
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ApplianceManager = exports.ApplianceManagerDisposedError = exports.DuplicateIdentifierError = exports.MissingIdentifierError = void 0;
3
+ exports.ApplianceManager = exports.ApplianceGroupNotFoundError = exports.ApplianceManagerDisposedError = exports.DuplicateIdentifierError = exports.MissingIdentifierError = void 0;
4
4
  const index_js_1 = require("../../index.cjs");
5
5
  const enyo_appliance_js_1 = require("../../types/enyo-appliance.cjs");
6
6
  /**
@@ -49,6 +49,20 @@ class ApplianceManagerDisposedError extends Error {
49
49
  }
50
50
  }
51
51
  exports.ApplianceManagerDisposedError = ApplianceManagerDisposedError;
52
+ /**
53
+ * Thrown by the {@link ApplianceManager} group helpers when no group with the
54
+ * given key exists in the package.
55
+ */
56
+ class ApplianceGroupNotFoundError extends Error {
57
+ /** The group key that was looked up */
58
+ key;
59
+ constructor(key) {
60
+ super(`No appliance group with key "${key}" exists.`);
61
+ this.name = 'ApplianceGroupNotFoundError';
62
+ this.key = key;
63
+ }
64
+ }
65
+ exports.ApplianceGroupNotFoundError = ApplianceGroupNotFoundError;
52
66
  /** Sub-objects on EnyoAppliance that {@link ApplianceManager} shallow-merges during updates. */
53
67
  const MERGEABLE_METADATA_KEYS = [
54
68
  'metadata',
@@ -220,6 +234,7 @@ class ApplianceManager {
220
234
  ...(appliance.cloudPackageId !== undefined && { cloudPackageId: appliance.cloudPackageId }),
221
235
  ...(appliance.availableFeatures !== undefined && { availableFeatures: appliance.availableFeatures }),
222
236
  ...(appliance.compatibilityModes !== undefined && { compatibilityModes: appliance.compatibilityModes }),
237
+ ...(appliance.hidden !== undefined && { hidden: appliance.hidden }),
223
238
  };
224
239
  let applianceData = newApplianceData;
225
240
  if (existingApplianceId) {
@@ -741,6 +756,170 @@ class ApplianceManager {
741
756
  }
742
757
  }
743
758
  }
759
+ /**
760
+ * Creates the group with the given {@link ApplianceGroupConfig.key}, or
761
+ * updates it when it already exists. The group is shown in the UI as one
762
+ * collapsible entry with the summed values of its members.
763
+ *
764
+ * An appliance belongs to at most one group, so members currently listed
765
+ * in another group of the package are taken out of it first; a group left
766
+ * empty by that is removed.
767
+ *
768
+ * @param config The group's key, name and member appliance IDs
769
+ * @returns The ID of the saved group
770
+ * @throws {Error} when `config.applianceIds` is empty
771
+ * @throws {ApplianceManagerDisposedError} when called after {@link dispose}
772
+ */
773
+ async createOrUpdateGroup(config) {
774
+ this.throwIfDisposed();
775
+ const applianceIds = [...new Set(config.applianceIds)];
776
+ if (applianceIds.length === 0) {
777
+ throw new Error(`Appliance group "${config.key}" needs at least one appliance; use removeGroup to drop it.`);
778
+ }
779
+ const groups = await this.loadGroups();
780
+ const existing = groups.find(g => g.key === config.key);
781
+ // Take the members out of every other group before saving, otherwise
782
+ // the host rejects the save for an appliance being in two groups.
783
+ const members = new Set(applianceIds);
784
+ for (const other of groups) {
785
+ if (other === existing || !other.applianceIds.some(id => members.has(id)))
786
+ continue;
787
+ const remaining = other.applianceIds.filter(id => !members.has(id));
788
+ if (remaining.length === 0) {
789
+ await this.deleteGroup(other.id);
790
+ }
791
+ else {
792
+ await this.persistGroup({ ...this.withoutId(other), applianceIds: remaining }, other.id);
793
+ }
794
+ }
795
+ return this.persistGroup({ key: config.key, name: config.name, applianceIds }, existing?.id);
796
+ }
797
+ /**
798
+ * Returns all appliance groups of the package.
799
+ *
800
+ * @throws {ApplianceManagerDisposedError} when called after {@link dispose}
801
+ */
802
+ async getGroups() {
803
+ this.throwIfDisposed();
804
+ return this.loadGroups();
805
+ }
806
+ /**
807
+ * Returns the group with the given key, or `null` when there is none.
808
+ *
809
+ * @param key The app-defined group key
810
+ * @throws {ApplianceManagerDisposedError} when called after {@link dispose}
811
+ */
812
+ async findGroupByKey(key) {
813
+ this.throwIfDisposed();
814
+ return (await this.loadGroups()).find(g => g.key === key) ?? null;
815
+ }
816
+ /**
817
+ * Returns the group an appliance belongs to, or `null` when it is not
818
+ * grouped.
819
+ *
820
+ * @param applianceId The appliance to look up
821
+ * @throws {ApplianceManagerDisposedError} when called after {@link dispose}
822
+ */
823
+ async findGroupOfAppliance(applianceId) {
824
+ this.throwIfDisposed();
825
+ return (await this.loadGroups()).find(g => g.applianceIds.includes(applianceId)) ?? null;
826
+ }
827
+ /**
828
+ * Adds an appliance to the end of an existing group, moving it out of any
829
+ * other group it was in. Does nothing when it is already a member.
830
+ *
831
+ * @param key The key of the group to add the appliance to
832
+ * @param applianceId The appliance to add
833
+ * @returns The ID of the group
834
+ * @throws {ApplianceGroupNotFoundError} when no group has the given key
835
+ * @throws {ApplianceManagerDisposedError} when called after {@link dispose}
836
+ */
837
+ async addApplianceToGroup(key, applianceId) {
838
+ this.throwIfDisposed();
839
+ const group = await this.findGroupByKey(key);
840
+ if (!group) {
841
+ throw new ApplianceGroupNotFoundError(key);
842
+ }
843
+ if (group.applianceIds.includes(applianceId)) {
844
+ return group.id;
845
+ }
846
+ return this.createOrUpdateGroup({
847
+ key,
848
+ name: group.name,
849
+ applianceIds: [...group.applianceIds, applianceId],
850
+ });
851
+ }
852
+ /**
853
+ * Takes an appliance out of its group, so the UI shows it individually
854
+ * again. A group left without members is removed.
855
+ *
856
+ * @param applianceId The appliance to take out of its group
857
+ * @returns `true` when the appliance was in a group, `false` otherwise
858
+ * @throws {ApplianceManagerDisposedError} when called after {@link dispose}
859
+ */
860
+ async removeApplianceFromGroup(applianceId) {
861
+ this.throwIfDisposed();
862
+ const group = await this.findGroupOfAppliance(applianceId);
863
+ if (!group) {
864
+ return false;
865
+ }
866
+ const remaining = group.applianceIds.filter(id => id !== applianceId);
867
+ if (remaining.length === 0) {
868
+ await this.deleteGroup(group.id);
869
+ }
870
+ else {
871
+ await this.persistGroup({ ...this.withoutId(group), applianceIds: remaining }, group.id);
872
+ }
873
+ return true;
874
+ }
875
+ /**
876
+ * Removes the group with the given key. Its members stay and are shown
877
+ * individually again.
878
+ *
879
+ * @param key The key of the group to remove
880
+ * @returns `true` when a group was removed, `false` when none had the key
881
+ * @throws {ApplianceManagerDisposedError} when called after {@link dispose}
882
+ */
883
+ async removeGroup(key) {
884
+ this.throwIfDisposed();
885
+ const group = await this.findGroupByKey(key);
886
+ if (!group) {
887
+ return false;
888
+ }
889
+ await this.deleteGroup(group.id);
890
+ return true;
891
+ }
892
+ /**
893
+ * Loads the package's appliance groups from storage. `protected` so
894
+ * subclasses (e.g. {@link InMemoryApplianceManager}) can swap the storage
895
+ * the group helpers run on.
896
+ */
897
+ async loadGroups() {
898
+ return this.energyApp.useAppliances().listGroups();
899
+ }
900
+ /**
901
+ * Saves an appliance group to storage.
902
+ *
903
+ * @param group The group to save
904
+ * @param groupId ID of the group to update, or `undefined` to create one
905
+ * @returns The ID of the saved group
906
+ */
907
+ async persistGroup(group, groupId) {
908
+ return this.energyApp.useAppliances().saveGroup(group, groupId);
909
+ }
910
+ /**
911
+ * Deletes an appliance group from storage.
912
+ *
913
+ * @param groupId ID of the group to delete
914
+ */
915
+ async deleteGroup(groupId) {
916
+ await this.energyApp.useAppliances().removeGroup(groupId);
917
+ }
918
+ /** Returns a copy of the group without its `id`, ready to be saved again. */
919
+ withoutId(group) {
920
+ const { id: _id, ...rest } = group;
921
+ return rest;
922
+ }
744
923
  /**
745
924
  * Gets the current identifier strategy.
746
925
  */
@@ -1,6 +1,6 @@
1
1
  import { EnergyApp } from "../../index.cjs";
2
2
  import type { EnyoNetworkDevice } from "../../types/enyo-network-device.cjs";
3
- import { EnyoAppliance, EnyoApplianceAvailableFeaturesEnum, EnyoApplianceConnectionType, EnyoApplianceMetadata, EnyoApplianceName, EnyoApplianceStateEnum, EnyoApplianceTopology, EnyoApplianceTypeEnum } from "../../types/enyo-appliance.cjs";
3
+ import { EnyoAppliance, EnyoApplianceAvailableFeaturesEnum, EnyoApplianceConnectionType, EnyoApplianceGroup, EnyoApplianceMetadata, EnyoApplianceName, EnyoApplianceStateEnum, EnyoApplianceTopology, EnyoApplianceTypeEnum } from "../../types/enyo-appliance.cjs";
4
4
  import type { EnyoApplianceCreatedFilter } from "../../packages/energy-app-appliance.cjs";
5
5
  import type { EnyoChargerApplianceMetadata } from "../../types/enyo-charger-appliance.cjs";
6
6
  import type { EnyoHeatpumpApplianceDomesticHotWater, EnyoHeatpumpApplianceHeatingCircuit, EnyoHeatpumpApplianceMetadata } from "../../types/enyo-heatpump-appliance.cjs";
@@ -40,6 +40,36 @@ export declare class DuplicateIdentifierError extends Error {
40
40
  export declare class ApplianceManagerDisposedError extends Error {
41
41
  constructor();
42
42
  }
43
+ /**
44
+ * Thrown by the {@link ApplianceManager} group helpers when no group with the
45
+ * given key exists in the package.
46
+ */
47
+ export declare class ApplianceGroupNotFoundError extends Error {
48
+ /** The group key that was looked up */
49
+ readonly key: string;
50
+ constructor(key: string);
51
+ }
52
+ /**
53
+ * Configuration for creating or updating an appliance group via
54
+ * {@link ApplianceManager.createOrUpdateGroup}. See {@link EnyoApplianceGroup}
55
+ * for what a group is and how the UI shows it.
56
+ */
57
+ export interface ApplianceGroupConfig {
58
+ /**
59
+ * Stable, app-defined key identifying the group within the package — e.g.
60
+ * the serial number of the gateway the members sit behind. A group with
61
+ * this key is updated in place; otherwise a new one is created.
62
+ */
63
+ key: string;
64
+ /** Name of the group shown in the UI, in the supported languages */
65
+ name: EnyoApplianceName[];
66
+ /**
67
+ * IDs of the member appliances (your package's own), in display order.
68
+ * Must not be empty — use {@link ApplianceManager.removeGroup} to drop a
69
+ * group. Appliances currently in another group are moved into this one.
70
+ */
71
+ applianceIds: string[];
72
+ }
43
73
  /**
44
74
  * Configuration for creating or updating an appliance.
45
75
  */
@@ -78,6 +108,12 @@ export interface ApplianceConfig {
78
108
  * appliance is created or updated.
79
109
  */
80
110
  cloudPackageId?: string;
111
+ /**
112
+ * Whether the appliance is hidden. Forwarded to {@link EnyoAppliance.hidden}
113
+ * when the appliance is created or updated. Omit to keep the stored value on
114
+ * an update; pass `false` to unhide it.
115
+ */
116
+ hidden?: boolean;
81
117
  }
82
118
  /**
83
119
  * Configuration options for the ApplianceManager.
@@ -434,6 +470,93 @@ export declare class ApplianceManager {
434
470
  * @throws {ApplianceManagerDisposedError} when called after {@link dispose}
435
471
  */
436
472
  setIdentifierStrategy(strategy: IdentifierStrategy, rebuildCache: boolean): Promise<void>;
473
+ /**
474
+ * Creates the group with the given {@link ApplianceGroupConfig.key}, or
475
+ * updates it when it already exists. The group is shown in the UI as one
476
+ * collapsible entry with the summed values of its members.
477
+ *
478
+ * An appliance belongs to at most one group, so members currently listed
479
+ * in another group of the package are taken out of it first; a group left
480
+ * empty by that is removed.
481
+ *
482
+ * @param config The group's key, name and member appliance IDs
483
+ * @returns The ID of the saved group
484
+ * @throws {Error} when `config.applianceIds` is empty
485
+ * @throws {ApplianceManagerDisposedError} when called after {@link dispose}
486
+ */
487
+ createOrUpdateGroup(config: ApplianceGroupConfig): Promise<string>;
488
+ /**
489
+ * Returns all appliance groups of the package.
490
+ *
491
+ * @throws {ApplianceManagerDisposedError} when called after {@link dispose}
492
+ */
493
+ getGroups(): Promise<EnyoApplianceGroup[]>;
494
+ /**
495
+ * Returns the group with the given key, or `null` when there is none.
496
+ *
497
+ * @param key The app-defined group key
498
+ * @throws {ApplianceManagerDisposedError} when called after {@link dispose}
499
+ */
500
+ findGroupByKey(key: string): Promise<EnyoApplianceGroup | null>;
501
+ /**
502
+ * Returns the group an appliance belongs to, or `null` when it is not
503
+ * grouped.
504
+ *
505
+ * @param applianceId The appliance to look up
506
+ * @throws {ApplianceManagerDisposedError} when called after {@link dispose}
507
+ */
508
+ findGroupOfAppliance(applianceId: string): Promise<EnyoApplianceGroup | null>;
509
+ /**
510
+ * Adds an appliance to the end of an existing group, moving it out of any
511
+ * other group it was in. Does nothing when it is already a member.
512
+ *
513
+ * @param key The key of the group to add the appliance to
514
+ * @param applianceId The appliance to add
515
+ * @returns The ID of the group
516
+ * @throws {ApplianceGroupNotFoundError} when no group has the given key
517
+ * @throws {ApplianceManagerDisposedError} when called after {@link dispose}
518
+ */
519
+ addApplianceToGroup(key: string, applianceId: string): Promise<string>;
520
+ /**
521
+ * Takes an appliance out of its group, so the UI shows it individually
522
+ * again. A group left without members is removed.
523
+ *
524
+ * @param applianceId The appliance to take out of its group
525
+ * @returns `true` when the appliance was in a group, `false` otherwise
526
+ * @throws {ApplianceManagerDisposedError} when called after {@link dispose}
527
+ */
528
+ removeApplianceFromGroup(applianceId: string): Promise<boolean>;
529
+ /**
530
+ * Removes the group with the given key. Its members stay and are shown
531
+ * individually again.
532
+ *
533
+ * @param key The key of the group to remove
534
+ * @returns `true` when a group was removed, `false` when none had the key
535
+ * @throws {ApplianceManagerDisposedError} when called after {@link dispose}
536
+ */
537
+ removeGroup(key: string): Promise<boolean>;
538
+ /**
539
+ * Loads the package's appliance groups from storage. `protected` so
540
+ * subclasses (e.g. {@link InMemoryApplianceManager}) can swap the storage
541
+ * the group helpers run on.
542
+ */
543
+ protected loadGroups(): Promise<EnyoApplianceGroup[]>;
544
+ /**
545
+ * Saves an appliance group to storage.
546
+ *
547
+ * @param group The group to save
548
+ * @param groupId ID of the group to update, or `undefined` to create one
549
+ * @returns The ID of the saved group
550
+ */
551
+ protected persistGroup(group: Omit<EnyoApplianceGroup, 'id'>, groupId: string | undefined): Promise<string>;
552
+ /**
553
+ * Deletes an appliance group from storage.
554
+ *
555
+ * @param groupId ID of the group to delete
556
+ */
557
+ protected deleteGroup(groupId: string): Promise<void>;
558
+ /** Returns a copy of the group without its `id`, ready to be saved again. */
559
+ private withoutId;
437
560
  /**
438
561
  * Gets the current identifier strategy.
439
562
  */
@@ -495,5 +618,6 @@ export interface PartialEnyoAppliance {
495
618
  * Optional identifier of the cloud-deployed energy app package that manages
496
619
  * this appliance. Mirrors {@link EnyoAppliance.cloudPackageId}.
497
620
  */
498
- cloudPackageId?: string;
621
+ cloudPackageId?: string; /** Whether the appliance is hidden. Mirrors {@link EnyoAppliance.hidden}. */
622
+ hidden?: boolean;
499
623
  }
@@ -13,6 +13,7 @@ class InMemoryApplianceManager extends appliance_manager_js_1.ApplianceManager {
13
13
  memoryStore = new Map();
14
14
  nextId = 1;
15
15
  networkDevicesStore = new Map();
16
+ groupStore = new Map();
16
17
  /**
17
18
  * Creates a new DemoApplianceManager instance.
18
19
  * @param energyApp The EnergyApp instance (not used for persistence in demo)
@@ -75,6 +76,7 @@ class InMemoryApplianceManager extends appliance_manager_js_1.ApplianceManager {
75
76
  ...(appliance.cloudPackageId !== undefined && { cloudPackageId: appliance.cloudPackageId }),
76
77
  ...(appliance.availableFeatures !== undefined && { availableFeatures: appliance.availableFeatures }),
77
78
  ...(appliance.compatibilityModes !== undefined && { compatibilityModes: appliance.compatibilityModes }),
79
+ ...(appliance.hidden !== undefined && { hidden: appliance.hidden }),
78
80
  };
79
81
  // On update, merge onto the stored appliance so type-specific metadata and
80
82
  // top-level fields that were not supplied in this call are preserved.
@@ -212,6 +214,19 @@ class InMemoryApplianceManager extends appliance_manager_js_1.ApplianceManager {
212
214
  if (this.memoryStore.has(applianceId)) {
213
215
  this.memoryStore.delete(applianceId);
214
216
  this.networkDevicesStore.delete(applianceId);
217
+ // Mirror the host: a removed appliance drops out of its group,
218
+ // and a group left without members is removed.
219
+ for (const group of [...this.groupStore.values()]) {
220
+ if (!group.applianceIds.includes(applianceId))
221
+ continue;
222
+ const remaining = group.applianceIds.filter(id => id !== applianceId);
223
+ if (remaining.length === 0) {
224
+ this.groupStore.delete(group.id);
225
+ }
226
+ else {
227
+ this.groupStore.set(group.id, { ...group, applianceIds: remaining });
228
+ }
229
+ }
215
230
  // Clean up cache (from parent class)
216
231
  this.clearCache();
217
232
  console.log(`[DEMO] Removed appliance ${applianceId}`);
@@ -287,12 +302,38 @@ class InMemoryApplianceManager extends appliance_manager_js_1.ApplianceManager {
287
302
  this.updateCache(appliance);
288
303
  }
289
304
  }
305
+ /**
306
+ * Loads the appliance groups from memory.
307
+ * @returns Copies of all stored groups
308
+ */
309
+ async loadGroups() {
310
+ return Array.from(this.groupStore.values(), g => structuredClone(g));
311
+ }
312
+ /**
313
+ * Saves an appliance group in memory.
314
+ * @param group The group to save
315
+ * @param groupId ID of the group to update, or `undefined` to create one
316
+ * @returns The ID of the saved group
317
+ */
318
+ async persistGroup(group, groupId) {
319
+ const id = groupId ?? (0, node_crypto_1.randomUUID)();
320
+ this.groupStore.set(id, { ...structuredClone(group), id });
321
+ return id;
322
+ }
323
+ /**
324
+ * Deletes an appliance group from memory.
325
+ * @param groupId ID of the group to delete
326
+ */
327
+ async deleteGroup(groupId) {
328
+ this.groupStore.delete(groupId);
329
+ }
290
330
  /**
291
331
  * Clears all data from memory (for testing purposes).
292
332
  */
293
333
  clearAllData() {
294
334
  this.memoryStore.clear();
295
335
  this.networkDevicesStore.clear();
336
+ this.groupStore.clear();
296
337
  this.clearCache();
297
338
  this.nextId = 1;
298
339
  console.log('[DEMO] All data cleared from memory');
@@ -1,5 +1,5 @@
1
1
  import type { EnergyApp } from "../../index.cjs";
2
- import { EnyoAppliance, EnyoApplianceConnectionType, EnyoApplianceMetadata, EnyoApplianceStateEnum, EnyoApplianceTypeEnum } from "../../types/enyo-appliance.cjs";
2
+ import { EnyoAppliance, EnyoApplianceConnectionType, EnyoApplianceGroup, EnyoApplianceMetadata, EnyoApplianceStateEnum, EnyoApplianceTypeEnum } from "../../types/enyo-appliance.cjs";
3
3
  import { ApplianceConfig, ApplianceManager, ApplianceManagerConfig, FindResult } from "./appliance-manager.cjs";
4
4
  import { IdentifierStrategy } from "./identifier-strategies.cjs";
5
5
  /**
@@ -11,6 +11,7 @@ export declare class InMemoryApplianceManager extends ApplianceManager {
11
11
  private memoryStore;
12
12
  private nextId;
13
13
  private networkDevicesStore;
14
+ private groupStore;
14
15
  /**
15
16
  * Creates a new DemoApplianceManager instance.
16
17
  * @param energyApp The EnergyApp instance (not used for persistence in demo)
@@ -99,6 +100,23 @@ export declare class InMemoryApplianceManager extends ApplianceManager {
99
100
  * Refreshes the cache with all appliances from memory.
100
101
  */
101
102
  refreshCache(): Promise<void>;
103
+ /**
104
+ * Loads the appliance groups from memory.
105
+ * @returns Copies of all stored groups
106
+ */
107
+ protected loadGroups(): Promise<EnyoApplianceGroup[]>;
108
+ /**
109
+ * Saves an appliance group in memory.
110
+ * @param group The group to save
111
+ * @param groupId ID of the group to update, or `undefined` to create one
112
+ * @returns The ID of the saved group
113
+ */
114
+ protected persistGroup(group: Omit<EnyoApplianceGroup, 'id'>, groupId: string | undefined): Promise<string>;
115
+ /**
116
+ * Deletes an appliance group from memory.
117
+ * @param groupId ID of the group to delete
118
+ */
119
+ protected deleteGroup(groupId: string): Promise<void>;
102
120
  /**
103
121
  * Clears all data from memory (for testing purposes).
104
122
  */