@enyo-energy/energy-app-sdk 0.0.131 → 0.0.132

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 (38) hide show
  1. package/README.md +163 -0
  2. package/dist/cjs/energy-app-permission.type.cjs +2 -0
  3. package/dist/cjs/energy-app-permission.type.d.cts +3 -1
  4. package/dist/cjs/energy-app.cjs +10 -0
  5. package/dist/cjs/energy-app.d.cts +9 -0
  6. package/dist/cjs/enyo-energy-app-sdk.d.cts +3 -0
  7. package/dist/cjs/implementations/network-devices/network-access-guard.cjs +239 -0
  8. package/dist/cjs/implementations/network-devices/network-access-guard.d.cts +165 -0
  9. package/dist/cjs/implementations/network-devices/network-device-manager.cjs +416 -0
  10. package/dist/cjs/implementations/network-devices/network-device-manager.d.cts +266 -0
  11. package/dist/cjs/index.cjs +3 -0
  12. package/dist/cjs/index.d.cts +3 -0
  13. package/dist/cjs/packages/energy-app-dynamic-price-forecast.cjs +2 -0
  14. package/dist/cjs/packages/energy-app-dynamic-price-forecast.d.cts +141 -0
  15. package/dist/cjs/types/enyo-battery-appliance.d.cts +7 -0
  16. package/dist/cjs/types/enyo-forecasting.d.cts +83 -0
  17. package/dist/cjs/types/enyo-inverter-appliance.d.cts +2 -0
  18. package/dist/cjs/version.cjs +1 -1
  19. package/dist/cjs/version.d.cts +1 -1
  20. package/dist/energy-app-permission.type.d.ts +3 -1
  21. package/dist/energy-app-permission.type.js +2 -0
  22. package/dist/energy-app.d.ts +9 -0
  23. package/dist/energy-app.js +10 -0
  24. package/dist/enyo-energy-app-sdk.d.ts +3 -0
  25. package/dist/implementations/network-devices/network-access-guard.d.ts +165 -0
  26. package/dist/implementations/network-devices/network-access-guard.js +235 -0
  27. package/dist/implementations/network-devices/network-device-manager.d.ts +266 -0
  28. package/dist/implementations/network-devices/network-device-manager.js +412 -0
  29. package/dist/index.d.ts +3 -0
  30. package/dist/index.js +3 -0
  31. package/dist/packages/energy-app-dynamic-price-forecast.d.ts +141 -0
  32. package/dist/packages/energy-app-dynamic-price-forecast.js +1 -0
  33. package/dist/types/enyo-battery-appliance.d.ts +7 -0
  34. package/dist/types/enyo-forecasting.d.ts +83 -0
  35. package/dist/types/enyo-inverter-appliance.d.ts +2 -0
  36. package/dist/version.d.ts +1 -1
  37. package/dist/version.js +1 -1
  38. package/package.json +1 -1
package/README.md CHANGED
@@ -23,6 +23,10 @@ The official TypeScript SDK for building Energy Apps on the enyo platform. Creat
23
23
  - [User Features](#user-features)
24
24
  - [App Intelligence](#app-intelligence)
25
25
  - [Advanced Modbus Integration](#advanced-modbus-integration)
26
+ - [Network Devices & Access Recovery](#network-devices--access-recovery)
27
+ - [NetworkAccessGuard](#networkaccessguard)
28
+ - [NetworkDeviceManager](#networkdevicemanager)
29
+ - [Startup pattern](#startup-pattern)
26
30
  - [Device Integrations](#device-integrations)
27
31
  - [IntegrationEnergyApp (Base Class)](#integrationenergyapp-base-class)
28
32
  - [HeatpumpIntegrationEnergyApp](#heatpumpintegrationenergyapp)
@@ -906,6 +910,165 @@ The Modbus implementation follows a clean, modular architecture:
906
910
 
907
911
  This modular design ensures maintainability, testability, and extensibility for future enhancements.
908
912
 
913
+ ## Network Devices & Access Recovery
914
+
915
+ Packages that talk to local hardware over TCP (Modbus, SunSpec, EEBUS over SHIP, REST) must deal with two failure modes the `useNetworkDevices()` API exposes only at a low level:
916
+
917
+ 1. **Network-access-denied errors** — `EnyoNetworkDevice.accessStatus` is device-wide (`granted | denied | pending`). It does **not** carry per-port grants. A device can report `'granted'` while your package never received (or has since lost) access to its Modbus port, and the first symptom is the runtime error `[NET] Network access denied: Host '...:502' is not in the allowed list.` from a poll cycle.
918
+ 2. **User-driven access transitions** — the user revokes or re-grants access via the UI; the SDK fires `listenForDeviceAccessChange`, and packages need to disconnect / reconnect accordingly.
919
+
920
+ The SDK ships two classes that encapsulate this lifecycle so packages don't reinvent it: a low-level **`NetworkAccessGuard`** for access-denied recovery, and a higher-level **`NetworkDeviceManager`** that wires the guard together with all the network-device listeners and the package's `ApplianceManager`.
921
+
922
+ ### NetworkAccessGuard
923
+
924
+ `NetworkAccessGuard` recovers from access-denied errors raised by the SDK's network layer. Construct one per package with the ports it needs and a restored-callback that reconnects whatever client was reading from the device.
925
+
926
+ ```typescript
927
+ import { NetworkAccessGuard } from '@enyo-energy/energy-app-sdk';
928
+
929
+ const accessGuard = new NetworkAccessGuard(energyApp, {
930
+ ports: [502],
931
+ onAccessRestored: async (networkDeviceId) => {
932
+ await myModbusPool.reconnect(networkDeviceId);
933
+ },
934
+ });
935
+
936
+ // Precondition before a Modbus connect:
937
+ if (!(await accessGuard.ensureAccess(networkDevice.id))) {
938
+ console.warn(`Modbus port access not granted for ${networkDevice.hostname}`);
939
+ return;
940
+ }
941
+
942
+ // Wrap any Modbus read so an access-denied error triggers recovery:
943
+ const registers = await accessGuard.withAccessGuard(networkDevice.id, () =>
944
+ modbusClient.readHoldingRegisters(40000, 4),
945
+ );
946
+ ```
947
+
948
+ Recovery lifecycle:
949
+
950
+ 1. A read fails inside `withAccessGuard`. The guard detects the access-denied error via `NetworkAccessGuard.isAccessDeniedError(error)`, re-throws it to the caller (so the current poll cycle fails fast), and kicks off recovery in the background.
951
+ 2. The guard calls `requestDeviceAccess(deviceId, ports)`. If the SDK answers `'granted'` synchronously (the port was just missing from the allow-list and no user prompt is needed), the `onAccessRestored` handler fires immediately.
952
+ 3. Otherwise the device stays in a pending set and the `listenForDeviceAccessChange` registration fires the handler when the SDK reports the device flipped to `'granted'`.
953
+
954
+ Re-entrancy: repeated `recoverAccess(...)` calls for the same device while a recovery is already in flight are coalesced — the handler runs exactly once per restoration.
955
+
956
+ The guard exposes:
957
+
958
+ | Method | Purpose |
959
+ | --- | --- |
960
+ | `static isAccessDeniedError(error)` | Recognise the SDK's access-denied error string |
961
+ | `ensureAccess(deviceId)` | Idempotent port-allow-list request before a connect |
962
+ | `withAccessGuard(deviceId, action)` | Wrap any async TCP call — recovers on access-denied |
963
+ | `recoverAccess(deviceId)` | Explicit recovery trigger after catching an access-denied error |
964
+ | `onAccessRestored(handler)` / `onAccessDenied(handler)` | Register additional handlers at runtime; returns a disposer |
965
+ | `isRecovering(deviceId)` | Introspect whether a recovery is in flight |
966
+ | `dispose()` | Tear down the SDK listener |
967
+
968
+ ### NetworkDeviceManager
969
+
970
+ `NetworkDeviceManager` is the recommended entry point for any package that owns appliances backed by NetworkDevices. It bundles a `NetworkAccessGuard` with the three NetworkDevice-related SDK listeners (`listenForDeviceAccessChange`, `listenForDetectedDevice`, `listenForNetworkDeviceRemoved`) and resolves every event into **per-appliance callbacks** by joining against the package's `ApplianceManager`.
971
+
972
+ ```typescript
973
+ import {
974
+ ApplianceManager,
975
+ EnergyApp,
976
+ NetworkDeviceManager,
977
+ } from '@enyo-energy/energy-app-sdk';
978
+
979
+ const energyApp = new EnergyApp();
980
+ const applianceManager = await ApplianceManager.initialize(energyApp);
981
+
982
+ const networkManager = await NetworkDeviceManager.initialize(
983
+ energyApp,
984
+ applianceManager,
985
+ {
986
+ ports: [502],
987
+ autoToggleApplianceState: true,
988
+ onApplianceAccessRestored: async ({ appliance, networkDeviceId }) => {
989
+ // Re-establish a Modbus session and restart the polling loop for this appliance.
990
+ await myModbusPool.reconnect(networkDeviceId);
991
+ },
992
+ onApplianceAccessRevoked: async ({ appliance, networkDeviceId }) => {
993
+ // User revoked access in the UI — tear down the connection.
994
+ await myModbusPool.disconnect(networkDeviceId);
995
+ },
996
+ onApplianceAccessDenied: async ({ appliance, networkDeviceId }) => {
997
+ // An access-denied error was just observed at runtime — mark the
998
+ // appliance offline. `autoToggleApplianceState: true` already does
999
+ // this; the handler is here for any custom side-effects.
1000
+ },
1001
+ onApplianceNetworkDeviceRemoved: async ({ appliance, networkDeviceId }) => {
1002
+ await myModbusPool.disconnect(networkDeviceId);
1003
+ },
1004
+ onNetworkDeviceDetected: async (devices) => {
1005
+ // New device found — classify + connect.
1006
+ for (const device of devices) {
1007
+ await classifyAndConnect(device);
1008
+ }
1009
+ },
1010
+ onNetworkDeviceAccessChanged: async (deviceId, status) => {
1011
+ // Optional: raw access-status passthrough, fires even for devices
1012
+ // the package has no appliances on yet. Useful for first-time
1013
+ // onboarding where a 'granted' transition needs to drive a discovery
1014
+ // pass before any appliance exists.
1015
+ },
1016
+ },
1017
+ );
1018
+
1019
+ // Every Modbus read inside the poll loop:
1020
+ await networkManager.withAccessGuard(networkDeviceId, () =>
1021
+ modbusClient.readHoldingRegisters(40000, 4),
1022
+ );
1023
+ ```
1024
+
1025
+ What the manager handles for you:
1026
+
1027
+ - **Access-denied recovery** — `withAccessGuard` / `ensureAccess` delegate to the bundled `NetworkAccessGuard`.
1028
+ - **User-driven transitions** — on `listenForDeviceAccessChange`, the manager dispatches `onApplianceAccessRestored` on `'granted'` and `onApplianceAccessRevoked` on `'denied'` / `'pending'`, resolving each transition into the per-appliance events your reconnect/disconnect code needs.
1029
+ - **Listener dedup** — the manager registers its access-change listener *before* the guard's, so a `'granted'` event during a recovery cycle dispatches only once (the manager observes `isRecovering(deviceId) === true` and skips, letting the guard's own restored callback win).
1030
+ - **Device removal** — on `listenForNetworkDeviceRemoved`, the manager fires `onApplianceNetworkDeviceRemoved` per affected appliance and clears its cache.
1031
+ - **Optional auto-state toggle** — with `autoToggleApplianceState: true`, the manager flips affected appliances to `EnyoApplianceStateEnum.Offline` on denial / revocation / removal, and back to `EnyoApplianceStateEnum.Connected` on restoration, via `applianceManager.updateApplianceState(...)`.
1032
+
1033
+ Every handler is also registerable at runtime via `manager.onApplianceAccessRestored(fn)` / `onApplianceAccessDenied(fn)` / `onApplianceAccessRevoked(fn)` / `onApplianceNetworkDeviceRemoved(fn)` / `onNetworkDeviceDetected(fn)` / `onNetworkDeviceAccessChanged(fn)`, each returning a disposer.
1034
+
1035
+ ### Startup pattern
1036
+
1037
+ The SDK's `listenForDeviceAccessChange` only fires on *transitions* — devices that are already `'granted'` from a previous session won't trigger it. Recommended startup flow for a package that supports both first-onboarding and warm restarts:
1038
+
1039
+ ```typescript
1040
+ client.register(async () => {
1041
+ const applianceManager = await ApplianceManager.initialize(client);
1042
+ const networkManager = await NetworkDeviceManager.initialize(
1043
+ client,
1044
+ applianceManager,
1045
+ {
1046
+ ports: [502],
1047
+ onApplianceAccessRestored: ({ networkDeviceId }) => connectDevice(networkDeviceId),
1048
+ onApplianceAccessRevoked: ({ networkDeviceId }) => disconnectDevice(networkDeviceId),
1049
+ onNetworkDeviceDetected: async (devices) => {
1050
+ for (const device of devices) await connectDevice(device.id);
1051
+ },
1052
+ },
1053
+ );
1054
+
1055
+ // Warm-restart: reconnect to devices that already have access.
1056
+ const granted = await client.useNetworkDevices().getDevices({ accessStatus: 'granted' });
1057
+ for (const device of granted) {
1058
+ await connectDevice(device.id);
1059
+ }
1060
+
1061
+ client.updateEnergyAppState(EnergyAppStateEnum.Running);
1062
+ });
1063
+
1064
+ async function connectDevice(networkDeviceId: string) {
1065
+ if (!(await networkManager.ensureAccess(networkDeviceId))) return;
1066
+ // ...classify, open modbus client, register appliances...
1067
+ }
1068
+ ```
1069
+
1070
+ This pattern matches the wiring used by real Sungrow / Fronius energy-app packages: one `NetworkDeviceManager` per package, `ensureAccess` before every connect, `withAccessGuard` around every poll, and a single `getDevices({ accessStatus: 'granted' })` pass at startup to cover the warm-restart case.
1071
+
909
1072
  ## Device Integrations
910
1073
 
911
1074
  Device Integrations are the high-level building blocks for apps that **drive a real device** — a heatpump, EV wallbox, PV inverter, battery storage system, or air-conditioning unit. Each integration class hides the data-bus plumbing for its appliance type so you only implement the business logic that physically controls the device.
@@ -31,6 +31,8 @@ var EnergyAppPermissionTypeEnum;
31
31
  EnergyAppPermissionTypeEnum["WeatherForecastUse"] = "WeatherForecastUse";
32
32
  EnergyAppPermissionTypeEnum["PvForecastRegister"] = "PvForecastRegister";
33
33
  EnergyAppPermissionTypeEnum["PvForecastUse"] = "PvForecastUse";
34
+ EnergyAppPermissionTypeEnum["DynamicPriceForecastRegister"] = "DynamicPriceForecastRegister";
35
+ EnergyAppPermissionTypeEnum["DynamicPriceForecastUse"] = "DynamicPriceForecastUse";
34
36
  EnergyAppPermissionTypeEnum["PvSystemRegister"] = "PvSystemRegister";
35
37
  EnergyAppPermissionTypeEnum["PvSystemUse"] = "PvSystemUse";
36
38
  EnergyAppPermissionTypeEnum["InverterControlCommands"] = "InverterControlCommands";
@@ -1,4 +1,4 @@
1
- export type EnergyAppPermissionType = 'RestrictedInternetAccess' | 'NetworkDeviceDiscovery' | 'NetworkDeviceSearch' | 'NetworkDeviceAccess' | 'Modbus' | 'Storage' | 'Appliance' | 'AllAppliances' | 'SendDataBusValues' | 'SubscribeDataBus' | 'SendDataBusCommands' | 'OcppServer' | 'ChargingCard' | 'Vehicle' | 'Charge' | 'SecretManager' | 'LocationZipCode' | 'LocationCoordinates' | 'Timeseries' | 'EnergyManagerInfo' | 'ElectricityTariff' | 'WeatherForecastRegister' | 'WeatherForecastUse' | 'PvForecastRegister' | 'PvForecastUse' | 'PvSystemRegister' | 'PvSystemUse' | 'InverterControlCommands' | 'BatteryControlCommands' | 'ChargerControlCommands' | 'ModbusRtu' | 'EnergyPrices' | 'EnergyManager' | 'EebusDeviceManagement' | 'EebusDataAccess' | 'EebusControl' | 'Mqtt' | 'Bluetooth' | 'Wifi' | 'ChildProcess' | 'Udp';
1
+ export type EnergyAppPermissionType = 'RestrictedInternetAccess' | 'NetworkDeviceDiscovery' | 'NetworkDeviceSearch' | 'NetworkDeviceAccess' | 'Modbus' | 'Storage' | 'Appliance' | 'AllAppliances' | 'SendDataBusValues' | 'SubscribeDataBus' | 'SendDataBusCommands' | 'OcppServer' | 'ChargingCard' | 'Vehicle' | 'Charge' | 'SecretManager' | 'LocationZipCode' | 'LocationCoordinates' | 'Timeseries' | 'EnergyManagerInfo' | 'ElectricityTariff' | 'WeatherForecastRegister' | 'WeatherForecastUse' | 'PvForecastRegister' | 'PvForecastUse' | 'DynamicPriceForecastRegister' | 'DynamicPriceForecastUse' | 'PvSystemRegister' | 'PvSystemUse' | 'InverterControlCommands' | 'BatteryControlCommands' | 'ChargerControlCommands' | 'ModbusRtu' | 'EnergyPrices' | 'EnergyManager' | 'EebusDeviceManagement' | 'EebusDataAccess' | 'EebusControl' | 'Mqtt' | 'Bluetooth' | 'Wifi' | 'ChildProcess' | 'Udp';
2
2
  export declare enum EnergyAppPermissionTypeEnum {
3
3
  RestrictedInternetAccess = "RestrictedInternetAccess",
4
4
  NetworkDeviceDiscovery = "NetworkDeviceDiscovery",
@@ -28,6 +28,8 @@ export declare enum EnergyAppPermissionTypeEnum {
28
28
  WeatherForecastUse = "WeatherForecastUse",
29
29
  PvForecastRegister = "PvForecastRegister",
30
30
  PvForecastUse = "PvForecastUse",
31
+ DynamicPriceForecastRegister = "DynamicPriceForecastRegister",
32
+ DynamicPriceForecastUse = "DynamicPriceForecastUse",
31
33
  PvSystemRegister = "PvSystemRegister",
32
34
  PvSystemUse = "PvSystemUse",
33
35
  InverterControlCommands = "InverterControlCommands",
@@ -167,6 +167,16 @@ class EnergyApp {
167
167
  usePvForecasting() {
168
168
  return this.energyAppSdk.usePvForecasting();
169
169
  }
170
+ /**
171
+ * Gets the Dynamic Price Forecast API for publishing and consuming
172
+ * forward-looking electricity price forecasts (e.g. day-ahead spot
173
+ * prices). The data is forecast only — see
174
+ * {@link EnergyAppDynamicPriceForecast} for the full contract.
175
+ * @returns The Dynamic Price Forecast API instance
176
+ */
177
+ useDynamicPriceForecast() {
178
+ return this.energyAppSdk.useDynamicPriceForecast();
179
+ }
170
180
  /**
171
181
  * Gets the PV System API for managing PV system registrations and configurations.
172
182
  * Provides methods to register, retrieve, update, and remove PV systems
@@ -22,6 +22,7 @@ import { EnergyAppEnergyManager } from "./packages/energy-app-energy-manager.cjs
22
22
  import { EnergyAppElectricityTariff } from "./packages/energy-app-electricity-tariff.cjs";
23
23
  import { EnergyAppWeatherForecasting } from "./packages/energy-app-weather-forecasting.cjs";
24
24
  import { EnergyAppPvForecasting } from "./packages/energy-app-pv-forecasting.cjs";
25
+ import { EnergyAppDynamicPriceForecast } from "./packages/energy-app-dynamic-price-forecast.cjs";
25
26
  import { EnergyAppPvSystem } from "./packages/energy-app-pv-system.cjs";
26
27
  import { EnergyAppSequenceGenerator } from "./packages/energy-app-sequence-generator.cjs";
27
28
  import { EnergyAppModbusRtu } from "./packages/energy-app-modbus-rtu.cjs";
@@ -125,6 +126,14 @@ export declare class EnergyApp implements EnyoEnergyAppSdk {
125
126
  * @returns The PV Forecasting API instance
126
127
  */
127
128
  usePvForecasting(): EnergyAppPvForecasting;
129
+ /**
130
+ * Gets the Dynamic Price Forecast API for publishing and consuming
131
+ * forward-looking electricity price forecasts (e.g. day-ahead spot
132
+ * prices). The data is forecast only — see
133
+ * {@link EnergyAppDynamicPriceForecast} for the full contract.
134
+ * @returns The Dynamic Price Forecast API instance
135
+ */
136
+ useDynamicPriceForecast(): EnergyAppDynamicPriceForecast;
128
137
  /**
129
138
  * Gets the PV System API for managing PV system registrations and configurations.
130
139
  * Provides methods to register, retrieve, update, and remove PV systems
@@ -21,6 +21,7 @@ import { EnergyAppEnergyManager } from "./packages/energy-app-energy-manager.cjs
21
21
  import { EnergyAppElectricityTariff } from "./packages/energy-app-electricity-tariff.cjs";
22
22
  import { EnergyAppWeatherForecasting } from "./packages/energy-app-weather-forecasting.cjs";
23
23
  import { EnergyAppPvForecasting } from "./packages/energy-app-pv-forecasting.cjs";
24
+ import { EnergyAppDynamicPriceForecast } from "./packages/energy-app-dynamic-price-forecast.cjs";
24
25
  import { EnergyAppPvSystem } from "./packages/energy-app-pv-system.cjs";
25
26
  import { EnergyAppSequenceGenerator } from "./packages/energy-app-sequence-generator.cjs";
26
27
  import { EnergyAppModbusRtu } from "./packages/energy-app-modbus-rtu.cjs";
@@ -102,6 +103,8 @@ export interface EnyoEnergyAppSdk {
102
103
  useWeatherForecasting: () => EnergyAppWeatherForecasting;
103
104
  /** Get the PV Forecasting API for managing PV forecast providers and retrieving PV forecasts */
104
105
  usePvForecasting: () => EnergyAppPvForecasting;
106
+ /** Get the Dynamic Price Forecast API for publishing and consuming forward-looking electricity price forecasts */
107
+ useDynamicPriceForecast: () => EnergyAppDynamicPriceForecast;
105
108
  /** Get the PV System API for managing PV system registrations and configurations */
106
109
  usePvSystem: () => EnergyAppPvSystem;
107
110
  /** Get the Sequence Generator API for generating unique sequential numbers per named sequence */
@@ -0,0 +1,239 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.NetworkAccessGuard = void 0;
4
+ /**
5
+ * Recovers from "Network access denied" failures raised by the SDK's
6
+ * network layer when a Modbus (or other TCP) call hits a host whose
7
+ * port is not in the package's allowed list.
8
+ *
9
+ * Why this exists: `EnyoNetworkDevice.accessStatus` is device-wide
10
+ * (`granted | denied | pending`) — it does NOT carry per-port grants.
11
+ * A device can report `'granted'` while our package never received
12
+ * (or has since lost) access to its Modbus port, and the first
13
+ * symptom is a runtime `[NET] Network access denied: Host '...:502'
14
+ * is not in the allowed list. Allowed origins: [], Allowed network
15
+ * devices: []` error from a poll cycle. The guard provides one place
16
+ * to detect that error, re-request access, and dispatch a reconnect
17
+ * once access returns.
18
+ *
19
+ * Typical wiring:
20
+ * 1. Construct one guard per package with the ports it needs.
21
+ * 2. On every Modbus call site, either:
22
+ * - wrap the call in {@link withAccessGuard}, which recovers in
23
+ * the background on access-denied errors and re-throws, or
24
+ * - catch the error, check {@link isAccessDeniedError}, and call
25
+ * {@link recoverAccess} explicitly.
26
+ * 3. Register an `onAccessRestored` handler (via the config or
27
+ * {@link onAccessRestored}) that performs the reconnect.
28
+ *
29
+ * Re-entrancy: repeated {@link recoverAccess} calls for the same
30
+ * device while a recovery is already in flight are coalesced — the
31
+ * restored handlers run exactly once per restoration.
32
+ *
33
+ * Precondition use: {@link ensureAccess} exposes the same idempotent
34
+ * `requestDeviceAccess` call so callers can use a single API for both
35
+ * "before a connect, make sure we have access" and "we just lost
36
+ * access, recover it".
37
+ */
38
+ class NetworkAccessGuard {
39
+ energyApp;
40
+ /** Devices we've re-requested access for and are awaiting a 'granted' signal on. */
41
+ pending = new Set();
42
+ /** Listener IDs registered against the network-devices service. */
43
+ listenerIds = [];
44
+ restoredHandlers = new Set();
45
+ deniedHandlers = new Set();
46
+ ports;
47
+ enableLogging;
48
+ disposed = false;
49
+ /**
50
+ * Recognise the SDK's network-access-denied error so callers don't
51
+ * have to re-implement the substring check. Matches both variants
52
+ * observed in production logs.
53
+ * @param error The error to inspect (any value is accepted)
54
+ * @returns `true` if the error message looks like an access-denied error
55
+ */
56
+ static isAccessDeniedError(error) {
57
+ const message = error instanceof Error ? error.message : String(error);
58
+ return /Network access denied|not in the allowed list/i.test(message);
59
+ }
60
+ /**
61
+ * Constructs a guard and immediately subscribes to the SDK's
62
+ * device-access-change events.
63
+ * @param energyApp The {@link EnergyApp} instance used to call the network-devices API
64
+ * @param config Required ports plus optional initial handlers and logging flag
65
+ */
66
+ constructor(energyApp, config) {
67
+ this.energyApp = energyApp;
68
+ this.ports = config.ports;
69
+ this.enableLogging = config.enableLogging ?? true;
70
+ if (config.onAccessRestored) {
71
+ this.restoredHandlers.add(config.onAccessRestored);
72
+ }
73
+ if (config.onAccessDenied) {
74
+ this.deniedHandlers.add(config.onAccessDenied);
75
+ }
76
+ const listenerId = this.energyApp.useNetworkDevices().listenForDeviceAccessChange((deviceId, status) => this.handleAccessChange(deviceId, status));
77
+ this.listenerIds.push(listenerId);
78
+ }
79
+ /**
80
+ * Registers an additional handler to be invoked whenever access
81
+ * is restored for any NetworkDevice the guard is recovering.
82
+ * @param handler The handler to register
83
+ * @returns A disposer that removes the handler again
84
+ */
85
+ onAccessRestored(handler) {
86
+ this.restoredHandlers.add(handler);
87
+ return () => this.restoredHandlers.delete(handler);
88
+ }
89
+ /**
90
+ * Registers an additional handler to be invoked whenever a
91
+ * recovery cycle starts (i.e. an access-denied error was just
92
+ * observed). Useful for marking dependent appliances offline.
93
+ * @param handler The handler to register
94
+ * @returns A disposer that removes the handler again
95
+ */
96
+ onAccessDenied(handler) {
97
+ this.deniedHandlers.add(handler);
98
+ return () => this.deniedHandlers.delete(handler);
99
+ }
100
+ /**
101
+ * Idempotently request access to the configured ports for the
102
+ * given NetworkDevice. Safe to call as a precondition before any
103
+ * Modbus operation.
104
+ * @param networkDeviceId The NetworkDevice to request access on
105
+ * @returns `true` when the SDK reports access is granted, `false` otherwise
106
+ */
107
+ async ensureAccess(networkDeviceId) {
108
+ try {
109
+ const result = await this.energyApp
110
+ .useNetworkDevices()
111
+ .requestDeviceAccess(networkDeviceId, this.ports);
112
+ return result.status === 'granted';
113
+ }
114
+ catch (error) {
115
+ if (this.enableLogging) {
116
+ console.warn(`[NetworkAccessGuard] requestDeviceAccess for ${networkDeviceId} failed: ${String(error)}`);
117
+ }
118
+ return false;
119
+ }
120
+ }
121
+ /**
122
+ * Wraps an async action so that an access-denied error
123
+ * automatically triggers a background recovery. The original
124
+ * error is re-thrown so the caller can fail-fast on the failed
125
+ * read; the next read after access is restored will succeed.
126
+ *
127
+ * Use this on Modbus call sites for the simplest integration:
128
+ * ```ts
129
+ * await guard.withAccessGuard(networkDeviceId, () =>
130
+ * modbusClient.readHoldingRegisters(...));
131
+ * ```
132
+ * @param networkDeviceId The NetworkDevice the action targets
133
+ * @param action The async operation to perform
134
+ * @returns The resolved value of `action`
135
+ */
136
+ async withAccessGuard(networkDeviceId, action) {
137
+ try {
138
+ return await action();
139
+ }
140
+ catch (error) {
141
+ if (NetworkAccessGuard.isAccessDeniedError(error)) {
142
+ void this.recoverAccess(networkDeviceId);
143
+ }
144
+ throw error;
145
+ }
146
+ }
147
+ /**
148
+ * Drive recovery from an access-denied error: notify denied
149
+ * handlers, re-request access, and fire the restored handlers
150
+ * once access is granted. The restored handlers run at most once
151
+ * per recovery, regardless of whether the grant is observed
152
+ * synchronously (from the request response) or asynchronously
153
+ * (from the access-change listener).
154
+ * @param networkDeviceId The NetworkDevice whose access needs to be recovered
155
+ */
156
+ async recoverAccess(networkDeviceId) {
157
+ if (this.disposed)
158
+ return;
159
+ if (this.pending.has(networkDeviceId)) {
160
+ if (this.enableLogging) {
161
+ console.debug(`[NetworkAccessGuard] recovery already in flight for ${networkDeviceId}`);
162
+ }
163
+ return;
164
+ }
165
+ this.pending.add(networkDeviceId);
166
+ if (this.enableLogging) {
167
+ console.log(`[NetworkAccessGuard] re-requesting access for ${networkDeviceId} after access-denied`);
168
+ }
169
+ await this.fireDenied(networkDeviceId);
170
+ const granted = await this.ensureAccess(networkDeviceId);
171
+ if (!granted) {
172
+ // Leave the device in `pending` so the listener can fire the
173
+ // handlers when the user eventually accepts the request.
174
+ return;
175
+ }
176
+ // Synchronous grant — the listener won't observe a transition,
177
+ // so fire the handlers ourselves and clear the pending mark.
178
+ this.pending.delete(networkDeviceId);
179
+ await this.fireRestored(networkDeviceId);
180
+ }
181
+ /**
182
+ * Returns `true` if a recovery cycle is currently in flight for
183
+ * the given NetworkDevice. Mainly useful for tests and
184
+ * introspection.
185
+ */
186
+ isRecovering(networkDeviceId) {
187
+ return this.pending.has(networkDeviceId);
188
+ }
189
+ /**
190
+ * Tears down the SDK listener and clears all handlers. After
191
+ * calling this the guard should no longer be used.
192
+ */
193
+ dispose() {
194
+ if (this.disposed)
195
+ return;
196
+ this.disposed = true;
197
+ const service = this.energyApp.useNetworkDevices();
198
+ for (const id of this.listenerIds) {
199
+ service.removeListener(id);
200
+ }
201
+ this.listenerIds.length = 0;
202
+ this.restoredHandlers.clear();
203
+ this.deniedHandlers.clear();
204
+ this.pending.clear();
205
+ }
206
+ async handleAccessChange(networkDeviceId, status) {
207
+ if (status !== 'granted')
208
+ return;
209
+ if (!this.pending.has(networkDeviceId))
210
+ return;
211
+ this.pending.delete(networkDeviceId);
212
+ await this.fireRestored(networkDeviceId);
213
+ }
214
+ async fireRestored(networkDeviceId) {
215
+ for (const handler of this.restoredHandlers) {
216
+ try {
217
+ await handler(networkDeviceId);
218
+ }
219
+ catch (error) {
220
+ if (this.enableLogging) {
221
+ console.warn(`[NetworkAccessGuard] access-restored handler for ${networkDeviceId} failed: ${String(error)}`);
222
+ }
223
+ }
224
+ }
225
+ }
226
+ async fireDenied(networkDeviceId) {
227
+ for (const handler of this.deniedHandlers) {
228
+ try {
229
+ await handler(networkDeviceId);
230
+ }
231
+ catch (error) {
232
+ if (this.enableLogging) {
233
+ console.warn(`[NetworkAccessGuard] access-denied handler for ${networkDeviceId} failed: ${String(error)}`);
234
+ }
235
+ }
236
+ }
237
+ }
238
+ }
239
+ exports.NetworkAccessGuard = NetworkAccessGuard;
@@ -0,0 +1,165 @@
1
+ import type { EnergyApp } from "../../energy-app.cjs";
2
+ /**
3
+ * Callback fired when access to a NetworkDevice's required ports
4
+ * becomes granted — either synchronously in response to a re-request
5
+ * or asynchronously via the SDK's `listenForDeviceAccessChange`
6
+ * listener.
7
+ */
8
+ export type AccessRestoredHandler = (networkDeviceId: string) => void | Promise<void>;
9
+ /**
10
+ * Callback fired when {@link NetworkAccessGuard.recoverAccess} is
11
+ * invoked because the caller has just observed an access-denied
12
+ * error. The handler runs at most once per recovery cycle, before
13
+ * the guard re-requests access.
14
+ */
15
+ export type AccessDeniedHandler = (networkDeviceId: string) => void | Promise<void>;
16
+ /**
17
+ * Construction options for {@link NetworkAccessGuard}.
18
+ */
19
+ export interface NetworkAccessGuardConfig {
20
+ /**
21
+ * TCP ports the package requires on every target NetworkDevice.
22
+ * Forwarded to `requestDeviceAccess(deviceId, ports)` whenever the
23
+ * guard needs to (re-)negotiate access.
24
+ */
25
+ ports: number[];
26
+ /**
27
+ * Optional handler invoked when access is restored. Multiple
28
+ * handlers can be registered after construction via
29
+ * {@link NetworkAccessGuard.onAccessRestored}.
30
+ */
31
+ onAccessRestored?: AccessRestoredHandler;
32
+ /**
33
+ * Optional handler invoked the moment {@link NetworkAccessGuard.recoverAccess}
34
+ * is triggered — i.e. when a caller has just observed an
35
+ * access-denied error. Lets consumers mark dependent appliances
36
+ * offline before recovery completes.
37
+ */
38
+ onAccessDenied?: AccessDeniedHandler;
39
+ /** Toggle internal logging. Defaults to `true`. */
40
+ enableLogging?: boolean;
41
+ }
42
+ /**
43
+ * Recovers from "Network access denied" failures raised by the SDK's
44
+ * network layer when a Modbus (or other TCP) call hits a host whose
45
+ * port is not in the package's allowed list.
46
+ *
47
+ * Why this exists: `EnyoNetworkDevice.accessStatus` is device-wide
48
+ * (`granted | denied | pending`) — it does NOT carry per-port grants.
49
+ * A device can report `'granted'` while our package never received
50
+ * (or has since lost) access to its Modbus port, and the first
51
+ * symptom is a runtime `[NET] Network access denied: Host '...:502'
52
+ * is not in the allowed list. Allowed origins: [], Allowed network
53
+ * devices: []` error from a poll cycle. The guard provides one place
54
+ * to detect that error, re-request access, and dispatch a reconnect
55
+ * once access returns.
56
+ *
57
+ * Typical wiring:
58
+ * 1. Construct one guard per package with the ports it needs.
59
+ * 2. On every Modbus call site, either:
60
+ * - wrap the call in {@link withAccessGuard}, which recovers in
61
+ * the background on access-denied errors and re-throws, or
62
+ * - catch the error, check {@link isAccessDeniedError}, and call
63
+ * {@link recoverAccess} explicitly.
64
+ * 3. Register an `onAccessRestored` handler (via the config or
65
+ * {@link onAccessRestored}) that performs the reconnect.
66
+ *
67
+ * Re-entrancy: repeated {@link recoverAccess} calls for the same
68
+ * device while a recovery is already in flight are coalesced — the
69
+ * restored handlers run exactly once per restoration.
70
+ *
71
+ * Precondition use: {@link ensureAccess} exposes the same idempotent
72
+ * `requestDeviceAccess` call so callers can use a single API for both
73
+ * "before a connect, make sure we have access" and "we just lost
74
+ * access, recover it".
75
+ */
76
+ export declare class NetworkAccessGuard {
77
+ private readonly energyApp;
78
+ /** Devices we've re-requested access for and are awaiting a 'granted' signal on. */
79
+ private readonly pending;
80
+ /** Listener IDs registered against the network-devices service. */
81
+ private readonly listenerIds;
82
+ private readonly restoredHandlers;
83
+ private readonly deniedHandlers;
84
+ private readonly ports;
85
+ private readonly enableLogging;
86
+ private disposed;
87
+ /**
88
+ * Recognise the SDK's network-access-denied error so callers don't
89
+ * have to re-implement the substring check. Matches both variants
90
+ * observed in production logs.
91
+ * @param error The error to inspect (any value is accepted)
92
+ * @returns `true` if the error message looks like an access-denied error
93
+ */
94
+ static isAccessDeniedError(error: unknown): boolean;
95
+ /**
96
+ * Constructs a guard and immediately subscribes to the SDK's
97
+ * device-access-change events.
98
+ * @param energyApp The {@link EnergyApp} instance used to call the network-devices API
99
+ * @param config Required ports plus optional initial handlers and logging flag
100
+ */
101
+ constructor(energyApp: EnergyApp, config: NetworkAccessGuardConfig);
102
+ /**
103
+ * Registers an additional handler to be invoked whenever access
104
+ * is restored for any NetworkDevice the guard is recovering.
105
+ * @param handler The handler to register
106
+ * @returns A disposer that removes the handler again
107
+ */
108
+ onAccessRestored(handler: AccessRestoredHandler): () => void;
109
+ /**
110
+ * Registers an additional handler to be invoked whenever a
111
+ * recovery cycle starts (i.e. an access-denied error was just
112
+ * observed). Useful for marking dependent appliances offline.
113
+ * @param handler The handler to register
114
+ * @returns A disposer that removes the handler again
115
+ */
116
+ onAccessDenied(handler: AccessDeniedHandler): () => void;
117
+ /**
118
+ * Idempotently request access to the configured ports for the
119
+ * given NetworkDevice. Safe to call as a precondition before any
120
+ * Modbus operation.
121
+ * @param networkDeviceId The NetworkDevice to request access on
122
+ * @returns `true` when the SDK reports access is granted, `false` otherwise
123
+ */
124
+ ensureAccess(networkDeviceId: string): Promise<boolean>;
125
+ /**
126
+ * Wraps an async action so that an access-denied error
127
+ * automatically triggers a background recovery. The original
128
+ * error is re-thrown so the caller can fail-fast on the failed
129
+ * read; the next read after access is restored will succeed.
130
+ *
131
+ * Use this on Modbus call sites for the simplest integration:
132
+ * ```ts
133
+ * await guard.withAccessGuard(networkDeviceId, () =>
134
+ * modbusClient.readHoldingRegisters(...));
135
+ * ```
136
+ * @param networkDeviceId The NetworkDevice the action targets
137
+ * @param action The async operation to perform
138
+ * @returns The resolved value of `action`
139
+ */
140
+ withAccessGuard<T>(networkDeviceId: string, action: () => Promise<T>): Promise<T>;
141
+ /**
142
+ * Drive recovery from an access-denied error: notify denied
143
+ * handlers, re-request access, and fire the restored handlers
144
+ * once access is granted. The restored handlers run at most once
145
+ * per recovery, regardless of whether the grant is observed
146
+ * synchronously (from the request response) or asynchronously
147
+ * (from the access-change listener).
148
+ * @param networkDeviceId The NetworkDevice whose access needs to be recovered
149
+ */
150
+ recoverAccess(networkDeviceId: string): Promise<void>;
151
+ /**
152
+ * Returns `true` if a recovery cycle is currently in flight for
153
+ * the given NetworkDevice. Mainly useful for tests and
154
+ * introspection.
155
+ */
156
+ isRecovering(networkDeviceId: string): boolean;
157
+ /**
158
+ * Tears down the SDK listener and clears all handlers. After
159
+ * calling this the guard should no longer be used.
160
+ */
161
+ dispose(): void;
162
+ private handleAccessChange;
163
+ private fireRestored;
164
+ private fireDenied;
165
+ }