@enyo-energy/energy-app-sdk 1.8.0 → 1.9.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
@@ -571,6 +571,86 @@ const registers = await client.readHoldingRegisters(1001, 10);
571
571
  await client.writeSingleRegister(2001, 500);
572
572
  ```
573
573
 
574
+ ##### Exceptions are data, not just failures
575
+
576
+ When a device *answers* but refuses an operation, the call rejects with a `ModbusExceptionError`
577
+ carrying the device's raw exception code. That is different from a timeout or a dropped socket, and
578
+ the distinction is the whole point: a permission probe is a write you expect to be refused.
579
+
580
+ ```typescript
581
+ import {ModbusExceptionError} from '@enyo-energy/energy-app-sdk';
582
+
583
+ // May this connection write at all? Write a register back its own value and see.
584
+ const current = (await client.readHoldingRegisters(43006, 1)).readUInt16BE(0);
585
+ try {
586
+ await client.writeSingleRegister(43006, current);
587
+ controlAllowed = true; // the write landed
588
+ } catch (error) {
589
+ if (error instanceof ModbusExceptionError) {
590
+ controlAllowed = false; // refused by the device, e.g. code 0x80
591
+ } else {
592
+ throw error; // the link is broken — a different problem
593
+ }
594
+ }
595
+ ```
596
+
597
+ Vendor-specific codes are passed through unvalidated (Huawei answers `0x80` for "permission
598
+ authentication failure or permission expiration", which is outside the standard `0x01`…`0x0B`
599
+ range). An exception response never recycles the socket.
600
+
601
+ ##### Vendor function codes: `sendRawPdu()`
602
+
603
+ Some devices reserve a function code of their own for things the standard eight codes cannot
604
+ express — most commonly an installer login that must succeed before any control write is accepted.
605
+ `sendRawPdu()` puts one PDU on the wire verbatim and hands back the raw answer, on the connection
606
+ the SDK already owns:
607
+
608
+ ```typescript
609
+ const response = await client.sendRawPdu(0x41, Buffer.from([0x24, ...challengeBytes]));
610
+
611
+ if (response.exceptionCode !== undefined) {
612
+ // The device refused. Expected often enough that it is a result, not a throw.
613
+ console.warn(`Vendor command refused with 0x${response.exceptionCode.toString(16)}`);
614
+ } else {
615
+ parseVendorReply(response.payload);
616
+ }
617
+ ```
618
+
619
+ - The SDK knows nothing about the vendor. Sub-commands, digests and keepalives stay in your app.
620
+ - The call queues on the same per-connection chain as every other request, so `noParallelRequests`
621
+ and `waitBetweenMessagesMs` hold — a login can never interleave with a block read that is already
622
+ in flight.
623
+ - Request payloads are **never logged**; only the function code and the payload length are. Vendor
624
+ handshakes carry credential digests.
625
+ - Payloads are capped at `MODBUS_MAX_PDU_PAYLOAD_BYTES` (252), keeping the frame inside the 253-byte
626
+ Modbus PDU limit. Function codes must be 1…127.
627
+ - It needs the same `Modbus` permission as the rest of the Modbus surface — an app that can already
628
+ call `writeMultipleRegisters` can write any holding register, so this grants reach, not privilege.
629
+
630
+ ##### Knowing when the socket was replaced
631
+
632
+ Anything the device tracks per *connection* rather than per device — a vendor login above all — dies
633
+ silently when the socket is recycled. The next write simply comes back refused. `onReconnect()` is
634
+ the signal that lets you re-establish it beforehand:
635
+
636
+ ```typescript
637
+ const stopListening = client.onReconnect(() => {
638
+ // Permission was granted to the old socket and is gone with it.
639
+ authenticated = false;
640
+ void loginAgain();
641
+ });
642
+
643
+ // Or check after the fact, without keeping a listener alive:
644
+ const before = client.connectionGeneration();
645
+ await doSomething();
646
+ if (client.connectionGeneration() !== before) {
647
+ // The socket was replaced in the meantime; connection-scoped state is void.
648
+ }
649
+ ```
650
+
651
+ Listeners are dropped automatically on `disconnect()`, and `onReconnect()` returns a function that
652
+ removes just yours.
653
+
574
654
  #### `useOcpp(): EnergyAppOcpp`
575
655
 
576
656
  Handle OCPP charging station communication:
@@ -56,6 +56,7 @@ __exportStar(require("./implementations/data-bus/data-bus-command-handler.cjs"),
56
56
  __exportStar(require("./types/enyo-currency.cjs"), exports);
57
57
  __exportStar(require("./packages/energy-app-sequence-generator.cjs"), exports);
58
58
  __exportStar(require("./packages/energy-app-energy-prices.cjs"), exports);
59
+ __exportStar(require("./packages/energy-app-modbus.cjs"), exports);
59
60
  __exportStar(require("./packages/energy-app-modbus-rtu.cjs"), exports);
60
61
  __exportStar(require("./types/enyo-modbus-server.cjs"), exports);
61
62
  __exportStar(require("./packages/energy-app-modbus-server.cjs"), exports);
@@ -40,6 +40,7 @@ export * from './implementations/data-bus/data-bus-command-handler.cjs';
40
40
  export * from './types/enyo-currency.cjs';
41
41
  export * from './packages/energy-app-sequence-generator.cjs';
42
42
  export * from './packages/energy-app-energy-prices.cjs';
43
+ export * from './packages/energy-app-modbus.cjs';
43
44
  export * from './packages/energy-app-modbus-rtu.cjs';
44
45
  export * from './types/enyo-modbus-server.cjs';
45
46
  export * from './packages/energy-app-modbus-server.cjs';
@@ -1,2 +1,66 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ModbusExceptionError = exports.MODBUS_MAX_PDU_PAYLOAD_BYTES = void 0;
4
+ /**
5
+ * The largest payload {@link EnergyAppModbusInstance.sendRawPdu} accepts, in bytes.
6
+ *
7
+ * A Modbus PDU is capped at 253 bytes; one of those is the function code, leaving 252 for
8
+ * the payload.
9
+ */
10
+ exports.MODBUS_MAX_PDU_PAYLOAD_BYTES = 252;
11
+ /**
12
+ * Thrown when a Modbus device answered a request with an exception response — the device is
13
+ * reachable and the frame was well-formed, it simply refused the operation.
14
+ *
15
+ * This is deliberately distinct from a transport failure. An app probing whether it is
16
+ * allowed to write (write a register back its own value and see what happens) needs
17
+ * "refused with code 0x80" to be reliably distinguishable from "timed out" or "socket
18
+ * dropped", and a generic `Error` with the code buried in its message string is not a
19
+ * contract anything can be built on.
20
+ *
21
+ * The socket is left intact when this is thrown: an exception response is a protocol answer,
22
+ * not a broken link.
23
+ *
24
+ * Note that {@link EnergyAppModbusInstance.sendRawPdu} does *not* throw this — a raw PDU
25
+ * reports its exception as data on {@link ModbusRawPduResponse.exceptionCode}, because there
26
+ * the exception is frequently the expected outcome rather than a failure.
27
+ *
28
+ * @example
29
+ * ```ts
30
+ * try {
31
+ * await instance.writeSingleRegister(43006, currentValue);
32
+ * // The write landed — this connection may write.
33
+ * } catch (error) {
34
+ * if (error instanceof ModbusExceptionError && error.exceptionCode === 0x80) {
35
+ * // Refused for lack of permission — a login is required.
36
+ * }
37
+ * throw error;
38
+ * }
39
+ * ```
40
+ */
41
+ class ModbusExceptionError extends Error {
42
+ exceptionCode;
43
+ functionCode;
44
+ address;
45
+ /**
46
+ * @param exceptionCode The raw exception code the device answered with. Standard codes
47
+ * are `0x01`…`0x0B`; vendor-specific codes are passed through
48
+ * unvalidated.
49
+ * @param functionCode The function code of the request that was refused (the request's
50
+ * own code, without the exception high bit).
51
+ * @param message Human-readable description, including the operation that failed.
52
+ * @param address The register or coil address involved, when the operation had
53
+ * one.
54
+ */
55
+ constructor(exceptionCode, functionCode, message, address) {
56
+ super(message);
57
+ this.exceptionCode = exceptionCode;
58
+ this.functionCode = functionCode;
59
+ this.address = address;
60
+ this.name = 'ModbusExceptionError';
61
+ // Restores the prototype chain so `instanceof` holds when this package is consumed
62
+ // from code compiled down to ES5.
63
+ Object.setPrototypeOf(this, ModbusExceptionError.prototype);
64
+ }
65
+ }
66
+ exports.ModbusExceptionError = ModbusExceptionError;
@@ -98,4 +98,163 @@ export interface EnergyAppModbusInstance {
98
98
  writeMultipleRegisters: (address: number, values: number[]) => Promise<void>;
99
99
  /** Read holding register string value */
100
100
  readRegisterStringValue: (address: number, quantity: number) => Promise<string>;
101
+ /**
102
+ * Sends one Modbus PDU verbatim on this connection and returns the raw response.
103
+ *
104
+ * This is the escape hatch for vendor function codes — the sub-commands a manufacturer
105
+ * reserves for itself outside the eight standard codes, such as the installer login a
106
+ * Huawei SUN2000 requires before it accepts a single control write. The transport owns
107
+ * framing (MBAP header, transaction id, unit id) exactly as it does for every other
108
+ * request; `payload` is put on the wire untouched and the response bytes come back
109
+ * untouched.
110
+ *
111
+ * The call is queued on the same per-connection chain as the standard reads and writes,
112
+ * so it honours {@link ModbusOptions.noParallelRequests} and
113
+ * {@link ModbusOptions.waitBetweenMessagesMs} and can never be interleaved with a block
114
+ * read that is already in flight.
115
+ *
116
+ * A device that answers with an exception resolves rather than rejects: the exception
117
+ * code is data (see {@link ModbusRawPduResponse.exceptionCode}), because for a
118
+ * permission probe "not allowed" is the expected answer and has to be distinguishable
119
+ * from a timeout or a dropped socket. Only transport failures reject.
120
+ *
121
+ * Request payloads are never logged — a vendor handshake typically carries a credential
122
+ * digest. Only the function code and the payload length appear in the logs.
123
+ *
124
+ * @param functionCode Modbus function code, 1…127. The exception space (`>= 0x80`) and
125
+ * `0` are rejected; the eight standard codes are allowed but the
126
+ * dedicated methods above are the better way to reach them.
127
+ * @param payload PDU bytes after the function code. At most
128
+ * {@link MODBUS_MAX_PDU_PAYLOAD_BYTES} bytes, so the frame stays
129
+ * within the 253-byte Modbus PDU limit. May be empty.
130
+ * @param options Optional per-call overrides.
131
+ * @throws Error if the parameters are out of range, if the connection is gone, or if the
132
+ * device does not answer within the deadline.
133
+ */
134
+ sendRawPdu: (functionCode: number, payload: Buffer, options?: ModbusRawPduOptions) => Promise<ModbusRawPduResponse>;
135
+ /**
136
+ * Registers a listener that fires whenever the underlying socket for this unit has been
137
+ * replaced — a reconnect after a dropped link, a recycled half-open socket, or a stale
138
+ * socket torn down by the transport.
139
+ *
140
+ * This matters for anything the device tracks per connection rather than per device.
141
+ * A vendor login, for instance, grants permission to the *socket* that authenticated:
142
+ * once that socket is gone the permission is gone with it, silently — the next write
143
+ * simply comes back with an exception. The listener is the point at which an app can
144
+ * re-authenticate before the next command instead of after a failed one.
145
+ *
146
+ * The listener fires once per socket replacement, after the old socket is gone and
147
+ * before the next operation is served. It is not called for the initial connect.
148
+ * Listeners must not throw; a throwing listener is logged and ignored.
149
+ *
150
+ * @param listener Called with no arguments once per socket replacement.
151
+ * @returns A function that removes the listener. Every listener is dropped automatically
152
+ * on {@link EnergyAppModbusInstance.disconnect}.
153
+ */
154
+ onReconnect: (listener: () => void) => () => void;
155
+ /**
156
+ * Monotonic counter of how many sockets this unit has been bound to, starting at `1` for
157
+ * the first live socket.
158
+ *
159
+ * The pull-based counterpart to {@link EnergyAppModbusInstance.onReconnect}: read it
160
+ * before and after an operation to tell whether connection-scoped state (a vendor login,
161
+ * a session token) survived, without having to keep a listener alive. A value that
162
+ * changed means the socket was replaced and anything scoped to it is void.
163
+ *
164
+ * Returns `0` while the instance has never had a live socket.
165
+ */
166
+ connectionGeneration: () => number;
167
+ }
168
+ /**
169
+ * Per-call options for {@link EnergyAppModbusInstance.sendRawPdu}.
170
+ */
171
+ export interface ModbusRawPduOptions {
172
+ /**
173
+ * Deadline for this single request in milliseconds. Defaults to the connection's
174
+ * {@link ModbusOptions.readTimeoutMs}. As with every other operation, exceeding it is
175
+ * treated as a dead link and recycles the socket.
176
+ */
177
+ timeoutMs?: number;
178
+ }
179
+ /**
180
+ * The raw answer to a {@link EnergyAppModbusInstance.sendRawPdu} call.
181
+ *
182
+ * Both a normal response and a device-level exception arrive here; only transport failures
183
+ * are thrown. Check {@link ModbusRawPduResponse.exceptionCode} first — it is `undefined`
184
+ * exactly when the device accepted the request.
185
+ */
186
+ export interface ModbusRawPduResponse {
187
+ /**
188
+ * The function code the device echoed. On an exception this is the requested code with
189
+ * the high bit set (`functionCode | 0x80`), which is what the device actually put on the
190
+ * wire.
191
+ */
192
+ functionCode: number;
193
+ /**
194
+ * The response bytes that followed the function code, with vendor framing intact.
195
+ * Empty on an exception — the exception code is reported separately rather than left in
196
+ * the payload.
197
+ */
198
+ payload: Buffer;
199
+ /**
200
+ * The device's exception code when it rejected the request, otherwise `undefined`.
201
+ *
202
+ * Standard Modbus defines `0x01`…`0x0B`, but vendors add their own — Huawei answers
203
+ * `0x80` for "permission authentication failure or permission expiration" — so this is
204
+ * the raw byte, passed through without validation against the standard set.
205
+ */
206
+ exceptionCode?: number;
207
+ }
208
+ /**
209
+ * The largest payload {@link EnergyAppModbusInstance.sendRawPdu} accepts, in bytes.
210
+ *
211
+ * A Modbus PDU is capped at 253 bytes; one of those is the function code, leaving 252 for
212
+ * the payload.
213
+ */
214
+ export declare const MODBUS_MAX_PDU_PAYLOAD_BYTES = 252;
215
+ /**
216
+ * Thrown when a Modbus device answered a request with an exception response — the device is
217
+ * reachable and the frame was well-formed, it simply refused the operation.
218
+ *
219
+ * This is deliberately distinct from a transport failure. An app probing whether it is
220
+ * allowed to write (write a register back its own value and see what happens) needs
221
+ * "refused with code 0x80" to be reliably distinguishable from "timed out" or "socket
222
+ * dropped", and a generic `Error` with the code buried in its message string is not a
223
+ * contract anything can be built on.
224
+ *
225
+ * The socket is left intact when this is thrown: an exception response is a protocol answer,
226
+ * not a broken link.
227
+ *
228
+ * Note that {@link EnergyAppModbusInstance.sendRawPdu} does *not* throw this — a raw PDU
229
+ * reports its exception as data on {@link ModbusRawPduResponse.exceptionCode}, because there
230
+ * the exception is frequently the expected outcome rather than a failure.
231
+ *
232
+ * @example
233
+ * ```ts
234
+ * try {
235
+ * await instance.writeSingleRegister(43006, currentValue);
236
+ * // The write landed — this connection may write.
237
+ * } catch (error) {
238
+ * if (error instanceof ModbusExceptionError && error.exceptionCode === 0x80) {
239
+ * // Refused for lack of permission — a login is required.
240
+ * }
241
+ * throw error;
242
+ * }
243
+ * ```
244
+ */
245
+ export declare class ModbusExceptionError extends Error {
246
+ readonly exceptionCode: number;
247
+ readonly functionCode: number;
248
+ readonly address?: number | undefined;
249
+ /**
250
+ * @param exceptionCode The raw exception code the device answered with. Standard codes
251
+ * are `0x01`…`0x0B`; vendor-specific codes are passed through
252
+ * unvalidated.
253
+ * @param functionCode The function code of the request that was refused (the request's
254
+ * own code, without the exception high bit).
255
+ * @param message Human-readable description, including the operation that failed.
256
+ * @param address The register or coil address involved, when the operation had
257
+ * one.
258
+ */
259
+ constructor(exceptionCode: number, functionCode: number, message: string, address?: number | undefined);
101
260
  }
@@ -90,9 +90,15 @@ var BatteryCommandForecastDirectionEnum;
90
90
  /** Power should flow from the battery into the home / grid (discharging). */
91
91
  BatteryCommandForecastDirectionEnum["Discharge"] = "discharge";
92
92
  /**
93
- * No energy flow the battery holds its current state-of-charge.
94
- * Use to mark idle periods between charge / discharge entries. The
95
- * entry's `powerW` must be `0`.
93
+ * Leave the slot to the battery's own logic for the duration of
94
+ * the entry the appliance decides itself whether to charge,
95
+ * discharge or stand still (auto mode).
96
+ *
97
+ * This is *not* a "no energy flow" marker: to block charging or
98
+ * discharging, use {@link BatteryCommandForecastDirectionEnum.Charge}
99
+ * respectively {@link BatteryCommandForecastDirectionEnum.Discharge}
100
+ * with `powerW = 0`. An `Idle` entry prescribes no setpoint, so its
101
+ * `powerW` must be `0`.
96
102
  */
97
103
  BatteryCommandForecastDirectionEnum["Idle"] = "idle";
98
104
  })(BatteryCommandForecastDirectionEnum || (exports.BatteryCommandForecastDirectionEnum = BatteryCommandForecastDirectionEnum = {}));
@@ -180,9 +180,15 @@ export declare enum BatteryCommandForecastDirectionEnum {
180
180
  /** Power should flow from the battery into the home / grid (discharging). */
181
181
  Discharge = "discharge",
182
182
  /**
183
- * No energy flow the battery holds its current state-of-charge.
184
- * Use to mark idle periods between charge / discharge entries. The
185
- * entry's `powerW` must be `0`.
183
+ * Leave the slot to the battery's own logic for the duration of
184
+ * the entry the appliance decides itself whether to charge,
185
+ * discharge or stand still (auto mode).
186
+ *
187
+ * This is *not* a "no energy flow" marker: to block charging or
188
+ * discharging, use {@link BatteryCommandForecastDirectionEnum.Charge}
189
+ * respectively {@link BatteryCommandForecastDirectionEnum.Discharge}
190
+ * with `powerW = 0`. An `Idle` entry prescribes no setpoint, so its
191
+ * `powerW` must be `0`.
186
192
  */
187
193
  Idle = "idle"
188
194
  }
@@ -205,11 +211,12 @@ export interface BatteryCommandForecastScheduleEntry {
205
211
  /**
206
212
  * Target power in Watts. Always non-negative — direction is carried
207
213
  * by {@link direction}, never by sign. A `powerW` of `0` together
208
- * with a `Charge` or `Discharge` direction means "hold at zero in
209
- * the named direction" (effectively idle until the next entry);
210
- * prefer the explicit
211
- * {@link BatteryCommandForecastDirectionEnum.Idle} direction for
212
- * unambiguous idle slots, in which case `powerW` must also be `0`.
214
+ * with a `Charge` or `Discharge` direction blocks that direction
215
+ * the battery must not charge respectively discharge until the next
216
+ * entry. Use that to keep the battery still, *not*
217
+ * {@link BatteryCommandForecastDirectionEnum.Idle}, which hands
218
+ * control back to the appliance's own logic and prescribes no
219
+ * setpoint at all (its `powerW` must be `0`).
213
220
  */
214
221
  powerW: number;
215
222
  }
@@ -324,9 +324,11 @@ var EnyoStorageScheduleModeEnum;
324
324
  * non-negative, and consumers never have to disambiguate `0` from
325
325
  * "direction-of-zero" or interpret sign conventions per integration.
326
326
  *
327
- * `Idle` is the explicit "no energy flow" markeruse it to insert
328
- * idle slots between charge / discharge periods. An entry with
329
- * `direction = Idle` must carry `powerW = 0`.
327
+ * `Idle` hands the slot back to the battery's own logic — it does *not*
328
+ * mean "no energy flow". To keep the battery still, use `Charge` or
329
+ * `Discharge` with `powerW = 0`, which blocks that direction for the
330
+ * duration of the entry. An `Idle` entry prescribes no setpoint, so it
331
+ * must carry `powerW = 0`.
330
332
  */
331
333
  var EnyoStorageScheduleDirectionEnum;
332
334
  (function (EnyoStorageScheduleDirectionEnum) {
@@ -335,9 +337,15 @@ var EnyoStorageScheduleDirectionEnum;
335
337
  /** Power should flow from the battery into the grid / home (discharging). */
336
338
  EnyoStorageScheduleDirectionEnum["Discharge"] = "discharge";
337
339
  /**
338
- * No energy flow the battery holds its current state-of-charge.
339
- * Use to mark idle periods between charge / discharge entries. The
340
- * entry's `powerW` must be `0`.
340
+ * Leave the slot to the battery's own logic for the duration of
341
+ * the entry the appliance decides itself whether to charge,
342
+ * discharge or stand still (auto mode).
343
+ *
344
+ * This is *not* a "no energy flow" marker: to block charging or
345
+ * discharging, use {@link EnyoStorageScheduleDirectionEnum.Charge}
346
+ * respectively {@link EnyoStorageScheduleDirectionEnum.Discharge}
347
+ * with `powerW = 0`. An `Idle` entry prescribes no setpoint, so its
348
+ * `powerW` must be `0`.
341
349
  */
342
350
  EnyoStorageScheduleDirectionEnum["Idle"] = "idle";
343
351
  })(EnyoStorageScheduleDirectionEnum || (exports.EnyoStorageScheduleDirectionEnum = EnyoStorageScheduleDirectionEnum = {}));
@@ -1637,9 +1637,11 @@ export declare enum EnyoStorageScheduleModeEnum {
1637
1637
  * non-negative, and consumers never have to disambiguate `0` from
1638
1638
  * "direction-of-zero" or interpret sign conventions per integration.
1639
1639
  *
1640
- * `Idle` is the explicit "no energy flow" markeruse it to insert
1641
- * idle slots between charge / discharge periods. An entry with
1642
- * `direction = Idle` must carry `powerW = 0`.
1640
+ * `Idle` hands the slot back to the battery's own logic — it does *not*
1641
+ * mean "no energy flow". To keep the battery still, use `Charge` or
1642
+ * `Discharge` with `powerW = 0`, which blocks that direction for the
1643
+ * duration of the entry. An `Idle` entry prescribes no setpoint, so it
1644
+ * must carry `powerW = 0`.
1643
1645
  */
1644
1646
  export declare enum EnyoStorageScheduleDirectionEnum {
1645
1647
  /** Power should flow from the grid into the battery (charging). */
@@ -1647,9 +1649,15 @@ export declare enum EnyoStorageScheduleDirectionEnum {
1647
1649
  /** Power should flow from the battery into the grid / home (discharging). */
1648
1650
  Discharge = "discharge",
1649
1651
  /**
1650
- * No energy flow the battery holds its current state-of-charge.
1651
- * Use to mark idle periods between charge / discharge entries. The
1652
- * entry's `powerW` must be `0`.
1652
+ * Leave the slot to the battery's own logic for the duration of
1653
+ * the entry the appliance decides itself whether to charge,
1654
+ * discharge or stand still (auto mode).
1655
+ *
1656
+ * This is *not* a "no energy flow" marker: to block charging or
1657
+ * discharging, use {@link EnyoStorageScheduleDirectionEnum.Charge}
1658
+ * respectively {@link EnyoStorageScheduleDirectionEnum.Discharge}
1659
+ * with `powerW = 0`. An `Idle` entry prescribes no setpoint, so its
1660
+ * `powerW` must be `0`.
1653
1661
  */
1654
1662
  Idle = "idle"
1655
1663
  }
@@ -1679,10 +1687,11 @@ export interface EnyoStorageScheduleEntry {
1679
1687
  * Target power for this setpoint in Watts. Always non-negative —
1680
1688
  * direction is carried by {@link direction}, never by sign. A
1681
1689
  * `powerW` of `0` together with a `Charge` or `Discharge` direction
1682
- * means "hold at zero in the named direction" (effectively idle
1683
- * until the next entry); prefer the explicit
1684
- * {@link EnyoStorageScheduleDirectionEnum.Idle} direction for
1685
- * unambiguous idle slots, in which case `powerW` must also be `0`.
1690
+ * blocks that direction the battery must not charge respectively
1691
+ * discharge until the next entry. Use that to keep the battery
1692
+ * still, *not* {@link EnyoStorageScheduleDirectionEnum.Idle}, which
1693
+ * hands control back to the appliance's own logic and prescribes no
1694
+ * setpoint at all (its `powerW` must be `0`).
1686
1695
  */
1687
1696
  powerW: number;
1688
1697
  }
@@ -9,7 +9,7 @@ exports.getSdkVersion = getSdkVersion;
9
9
  /**
10
10
  * Current version of the enyo Energy App SDK.
11
11
  */
12
- exports.SDK_VERSION = '1.8.0';
12
+ exports.SDK_VERSION = '1.9.0';
13
13
  /**
14
14
  * Gets the current SDK version.
15
15
  * @returns The semantic version string of the SDK
@@ -5,7 +5,7 @@
5
5
  /**
6
6
  * Current version of the enyo Energy App SDK.
7
7
  */
8
- export declare const SDK_VERSION = "1.8.0";
8
+ export declare const SDK_VERSION = "1.9.0";
9
9
  /**
10
10
  * Gets the current SDK version.
11
11
  * @returns The semantic version string of the SDK
package/dist/index.d.ts CHANGED
@@ -40,6 +40,7 @@ export * from './implementations/data-bus/data-bus-command-handler.js';
40
40
  export * from './types/enyo-currency.js';
41
41
  export * from './packages/energy-app-sequence-generator.js';
42
42
  export * from './packages/energy-app-energy-prices.js';
43
+ export * from './packages/energy-app-modbus.js';
43
44
  export * from './packages/energy-app-modbus-rtu.js';
44
45
  export * from './types/enyo-modbus-server.js';
45
46
  export * from './packages/energy-app-modbus-server.js';
package/dist/index.js CHANGED
@@ -40,6 +40,7 @@ export * from './implementations/data-bus/data-bus-command-handler.js';
40
40
  export * from './types/enyo-currency.js';
41
41
  export * from './packages/energy-app-sequence-generator.js';
42
42
  export * from './packages/energy-app-energy-prices.js';
43
+ export * from './packages/energy-app-modbus.js';
43
44
  export * from './packages/energy-app-modbus-rtu.js';
44
45
  export * from './types/enyo-modbus-server.js';
45
46
  export * from './packages/energy-app-modbus-server.js';
@@ -98,4 +98,163 @@ export interface EnergyAppModbusInstance {
98
98
  writeMultipleRegisters: (address: number, values: number[]) => Promise<void>;
99
99
  /** Read holding register string value */
100
100
  readRegisterStringValue: (address: number, quantity: number) => Promise<string>;
101
+ /**
102
+ * Sends one Modbus PDU verbatim on this connection and returns the raw response.
103
+ *
104
+ * This is the escape hatch for vendor function codes — the sub-commands a manufacturer
105
+ * reserves for itself outside the eight standard codes, such as the installer login a
106
+ * Huawei SUN2000 requires before it accepts a single control write. The transport owns
107
+ * framing (MBAP header, transaction id, unit id) exactly as it does for every other
108
+ * request; `payload` is put on the wire untouched and the response bytes come back
109
+ * untouched.
110
+ *
111
+ * The call is queued on the same per-connection chain as the standard reads and writes,
112
+ * so it honours {@link ModbusOptions.noParallelRequests} and
113
+ * {@link ModbusOptions.waitBetweenMessagesMs} and can never be interleaved with a block
114
+ * read that is already in flight.
115
+ *
116
+ * A device that answers with an exception resolves rather than rejects: the exception
117
+ * code is data (see {@link ModbusRawPduResponse.exceptionCode}), because for a
118
+ * permission probe "not allowed" is the expected answer and has to be distinguishable
119
+ * from a timeout or a dropped socket. Only transport failures reject.
120
+ *
121
+ * Request payloads are never logged — a vendor handshake typically carries a credential
122
+ * digest. Only the function code and the payload length appear in the logs.
123
+ *
124
+ * @param functionCode Modbus function code, 1…127. The exception space (`>= 0x80`) and
125
+ * `0` are rejected; the eight standard codes are allowed but the
126
+ * dedicated methods above are the better way to reach them.
127
+ * @param payload PDU bytes after the function code. At most
128
+ * {@link MODBUS_MAX_PDU_PAYLOAD_BYTES} bytes, so the frame stays
129
+ * within the 253-byte Modbus PDU limit. May be empty.
130
+ * @param options Optional per-call overrides.
131
+ * @throws Error if the parameters are out of range, if the connection is gone, or if the
132
+ * device does not answer within the deadline.
133
+ */
134
+ sendRawPdu: (functionCode: number, payload: Buffer, options?: ModbusRawPduOptions) => Promise<ModbusRawPduResponse>;
135
+ /**
136
+ * Registers a listener that fires whenever the underlying socket for this unit has been
137
+ * replaced — a reconnect after a dropped link, a recycled half-open socket, or a stale
138
+ * socket torn down by the transport.
139
+ *
140
+ * This matters for anything the device tracks per connection rather than per device.
141
+ * A vendor login, for instance, grants permission to the *socket* that authenticated:
142
+ * once that socket is gone the permission is gone with it, silently — the next write
143
+ * simply comes back with an exception. The listener is the point at which an app can
144
+ * re-authenticate before the next command instead of after a failed one.
145
+ *
146
+ * The listener fires once per socket replacement, after the old socket is gone and
147
+ * before the next operation is served. It is not called for the initial connect.
148
+ * Listeners must not throw; a throwing listener is logged and ignored.
149
+ *
150
+ * @param listener Called with no arguments once per socket replacement.
151
+ * @returns A function that removes the listener. Every listener is dropped automatically
152
+ * on {@link EnergyAppModbusInstance.disconnect}.
153
+ */
154
+ onReconnect: (listener: () => void) => () => void;
155
+ /**
156
+ * Monotonic counter of how many sockets this unit has been bound to, starting at `1` for
157
+ * the first live socket.
158
+ *
159
+ * The pull-based counterpart to {@link EnergyAppModbusInstance.onReconnect}: read it
160
+ * before and after an operation to tell whether connection-scoped state (a vendor login,
161
+ * a session token) survived, without having to keep a listener alive. A value that
162
+ * changed means the socket was replaced and anything scoped to it is void.
163
+ *
164
+ * Returns `0` while the instance has never had a live socket.
165
+ */
166
+ connectionGeneration: () => number;
167
+ }
168
+ /**
169
+ * Per-call options for {@link EnergyAppModbusInstance.sendRawPdu}.
170
+ */
171
+ export interface ModbusRawPduOptions {
172
+ /**
173
+ * Deadline for this single request in milliseconds. Defaults to the connection's
174
+ * {@link ModbusOptions.readTimeoutMs}. As with every other operation, exceeding it is
175
+ * treated as a dead link and recycles the socket.
176
+ */
177
+ timeoutMs?: number;
178
+ }
179
+ /**
180
+ * The raw answer to a {@link EnergyAppModbusInstance.sendRawPdu} call.
181
+ *
182
+ * Both a normal response and a device-level exception arrive here; only transport failures
183
+ * are thrown. Check {@link ModbusRawPduResponse.exceptionCode} first — it is `undefined`
184
+ * exactly when the device accepted the request.
185
+ */
186
+ export interface ModbusRawPduResponse {
187
+ /**
188
+ * The function code the device echoed. On an exception this is the requested code with
189
+ * the high bit set (`functionCode | 0x80`), which is what the device actually put on the
190
+ * wire.
191
+ */
192
+ functionCode: number;
193
+ /**
194
+ * The response bytes that followed the function code, with vendor framing intact.
195
+ * Empty on an exception — the exception code is reported separately rather than left in
196
+ * the payload.
197
+ */
198
+ payload: Buffer;
199
+ /**
200
+ * The device's exception code when it rejected the request, otherwise `undefined`.
201
+ *
202
+ * Standard Modbus defines `0x01`…`0x0B`, but vendors add their own — Huawei answers
203
+ * `0x80` for "permission authentication failure or permission expiration" — so this is
204
+ * the raw byte, passed through without validation against the standard set.
205
+ */
206
+ exceptionCode?: number;
207
+ }
208
+ /**
209
+ * The largest payload {@link EnergyAppModbusInstance.sendRawPdu} accepts, in bytes.
210
+ *
211
+ * A Modbus PDU is capped at 253 bytes; one of those is the function code, leaving 252 for
212
+ * the payload.
213
+ */
214
+ export declare const MODBUS_MAX_PDU_PAYLOAD_BYTES = 252;
215
+ /**
216
+ * Thrown when a Modbus device answered a request with an exception response — the device is
217
+ * reachable and the frame was well-formed, it simply refused the operation.
218
+ *
219
+ * This is deliberately distinct from a transport failure. An app probing whether it is
220
+ * allowed to write (write a register back its own value and see what happens) needs
221
+ * "refused with code 0x80" to be reliably distinguishable from "timed out" or "socket
222
+ * dropped", and a generic `Error` with the code buried in its message string is not a
223
+ * contract anything can be built on.
224
+ *
225
+ * The socket is left intact when this is thrown: an exception response is a protocol answer,
226
+ * not a broken link.
227
+ *
228
+ * Note that {@link EnergyAppModbusInstance.sendRawPdu} does *not* throw this — a raw PDU
229
+ * reports its exception as data on {@link ModbusRawPduResponse.exceptionCode}, because there
230
+ * the exception is frequently the expected outcome rather than a failure.
231
+ *
232
+ * @example
233
+ * ```ts
234
+ * try {
235
+ * await instance.writeSingleRegister(43006, currentValue);
236
+ * // The write landed — this connection may write.
237
+ * } catch (error) {
238
+ * if (error instanceof ModbusExceptionError && error.exceptionCode === 0x80) {
239
+ * // Refused for lack of permission — a login is required.
240
+ * }
241
+ * throw error;
242
+ * }
243
+ * ```
244
+ */
245
+ export declare class ModbusExceptionError extends Error {
246
+ readonly exceptionCode: number;
247
+ readonly functionCode: number;
248
+ readonly address?: number | undefined;
249
+ /**
250
+ * @param exceptionCode The raw exception code the device answered with. Standard codes
251
+ * are `0x01`…`0x0B`; vendor-specific codes are passed through
252
+ * unvalidated.
253
+ * @param functionCode The function code of the request that was refused (the request's
254
+ * own code, without the exception high bit).
255
+ * @param message Human-readable description, including the operation that failed.
256
+ * @param address The register or coil address involved, when the operation had
257
+ * one.
258
+ */
259
+ constructor(exceptionCode: number, functionCode: number, message: string, address?: number | undefined);
101
260
  }
@@ -1 +1,62 @@
1
- export {};
1
+ /**
2
+ * The largest payload {@link EnergyAppModbusInstance.sendRawPdu} accepts, in bytes.
3
+ *
4
+ * A Modbus PDU is capped at 253 bytes; one of those is the function code, leaving 252 for
5
+ * the payload.
6
+ */
7
+ export const MODBUS_MAX_PDU_PAYLOAD_BYTES = 252;
8
+ /**
9
+ * Thrown when a Modbus device answered a request with an exception response — the device is
10
+ * reachable and the frame was well-formed, it simply refused the operation.
11
+ *
12
+ * This is deliberately distinct from a transport failure. An app probing whether it is
13
+ * allowed to write (write a register back its own value and see what happens) needs
14
+ * "refused with code 0x80" to be reliably distinguishable from "timed out" or "socket
15
+ * dropped", and a generic `Error` with the code buried in its message string is not a
16
+ * contract anything can be built on.
17
+ *
18
+ * The socket is left intact when this is thrown: an exception response is a protocol answer,
19
+ * not a broken link.
20
+ *
21
+ * Note that {@link EnergyAppModbusInstance.sendRawPdu} does *not* throw this — a raw PDU
22
+ * reports its exception as data on {@link ModbusRawPduResponse.exceptionCode}, because there
23
+ * the exception is frequently the expected outcome rather than a failure.
24
+ *
25
+ * @example
26
+ * ```ts
27
+ * try {
28
+ * await instance.writeSingleRegister(43006, currentValue);
29
+ * // The write landed — this connection may write.
30
+ * } catch (error) {
31
+ * if (error instanceof ModbusExceptionError && error.exceptionCode === 0x80) {
32
+ * // Refused for lack of permission — a login is required.
33
+ * }
34
+ * throw error;
35
+ * }
36
+ * ```
37
+ */
38
+ export class ModbusExceptionError extends Error {
39
+ exceptionCode;
40
+ functionCode;
41
+ address;
42
+ /**
43
+ * @param exceptionCode The raw exception code the device answered with. Standard codes
44
+ * are `0x01`…`0x0B`; vendor-specific codes are passed through
45
+ * unvalidated.
46
+ * @param functionCode The function code of the request that was refused (the request's
47
+ * own code, without the exception high bit).
48
+ * @param message Human-readable description, including the operation that failed.
49
+ * @param address The register or coil address involved, when the operation had
50
+ * one.
51
+ */
52
+ constructor(exceptionCode, functionCode, message, address) {
53
+ super(message);
54
+ this.exceptionCode = exceptionCode;
55
+ this.functionCode = functionCode;
56
+ this.address = address;
57
+ this.name = 'ModbusExceptionError';
58
+ // Restores the prototype chain so `instanceof` holds when this package is consumed
59
+ // from code compiled down to ES5.
60
+ Object.setPrototypeOf(this, ModbusExceptionError.prototype);
61
+ }
62
+ }
@@ -180,9 +180,15 @@ export declare enum BatteryCommandForecastDirectionEnum {
180
180
  /** Power should flow from the battery into the home / grid (discharging). */
181
181
  Discharge = "discharge",
182
182
  /**
183
- * No energy flow the battery holds its current state-of-charge.
184
- * Use to mark idle periods between charge / discharge entries. The
185
- * entry's `powerW` must be `0`.
183
+ * Leave the slot to the battery's own logic for the duration of
184
+ * the entry the appliance decides itself whether to charge,
185
+ * discharge or stand still (auto mode).
186
+ *
187
+ * This is *not* a "no energy flow" marker: to block charging or
188
+ * discharging, use {@link BatteryCommandForecastDirectionEnum.Charge}
189
+ * respectively {@link BatteryCommandForecastDirectionEnum.Discharge}
190
+ * with `powerW = 0`. An `Idle` entry prescribes no setpoint, so its
191
+ * `powerW` must be `0`.
186
192
  */
187
193
  Idle = "idle"
188
194
  }
@@ -205,11 +211,12 @@ export interface BatteryCommandForecastScheduleEntry {
205
211
  /**
206
212
  * Target power in Watts. Always non-negative — direction is carried
207
213
  * by {@link direction}, never by sign. A `powerW` of `0` together
208
- * with a `Charge` or `Discharge` direction means "hold at zero in
209
- * the named direction" (effectively idle until the next entry);
210
- * prefer the explicit
211
- * {@link BatteryCommandForecastDirectionEnum.Idle} direction for
212
- * unambiguous idle slots, in which case `powerW` must also be `0`.
214
+ * with a `Charge` or `Discharge` direction blocks that direction
215
+ * the battery must not charge respectively discharge until the next
216
+ * entry. Use that to keep the battery still, *not*
217
+ * {@link BatteryCommandForecastDirectionEnum.Idle}, which hands
218
+ * control back to the appliance's own logic and prescribes no
219
+ * setpoint at all (its `powerW` must be `0`).
213
220
  */
214
221
  powerW: number;
215
222
  }
@@ -87,9 +87,15 @@ export var BatteryCommandForecastDirectionEnum;
87
87
  /** Power should flow from the battery into the home / grid (discharging). */
88
88
  BatteryCommandForecastDirectionEnum["Discharge"] = "discharge";
89
89
  /**
90
- * No energy flow the battery holds its current state-of-charge.
91
- * Use to mark idle periods between charge / discharge entries. The
92
- * entry's `powerW` must be `0`.
90
+ * Leave the slot to the battery's own logic for the duration of
91
+ * the entry the appliance decides itself whether to charge,
92
+ * discharge or stand still (auto mode).
93
+ *
94
+ * This is *not* a "no energy flow" marker: to block charging or
95
+ * discharging, use {@link BatteryCommandForecastDirectionEnum.Charge}
96
+ * respectively {@link BatteryCommandForecastDirectionEnum.Discharge}
97
+ * with `powerW = 0`. An `Idle` entry prescribes no setpoint, so its
98
+ * `powerW` must be `0`.
93
99
  */
94
100
  BatteryCommandForecastDirectionEnum["Idle"] = "idle";
95
101
  })(BatteryCommandForecastDirectionEnum || (BatteryCommandForecastDirectionEnum = {}));
@@ -1637,9 +1637,11 @@ export declare enum EnyoStorageScheduleModeEnum {
1637
1637
  * non-negative, and consumers never have to disambiguate `0` from
1638
1638
  * "direction-of-zero" or interpret sign conventions per integration.
1639
1639
  *
1640
- * `Idle` is the explicit "no energy flow" markeruse it to insert
1641
- * idle slots between charge / discharge periods. An entry with
1642
- * `direction = Idle` must carry `powerW = 0`.
1640
+ * `Idle` hands the slot back to the battery's own logic — it does *not*
1641
+ * mean "no energy flow". To keep the battery still, use `Charge` or
1642
+ * `Discharge` with `powerW = 0`, which blocks that direction for the
1643
+ * duration of the entry. An `Idle` entry prescribes no setpoint, so it
1644
+ * must carry `powerW = 0`.
1643
1645
  */
1644
1646
  export declare enum EnyoStorageScheduleDirectionEnum {
1645
1647
  /** Power should flow from the grid into the battery (charging). */
@@ -1647,9 +1649,15 @@ export declare enum EnyoStorageScheduleDirectionEnum {
1647
1649
  /** Power should flow from the battery into the grid / home (discharging). */
1648
1650
  Discharge = "discharge",
1649
1651
  /**
1650
- * No energy flow the battery holds its current state-of-charge.
1651
- * Use to mark idle periods between charge / discharge entries. The
1652
- * entry's `powerW` must be `0`.
1652
+ * Leave the slot to the battery's own logic for the duration of
1653
+ * the entry the appliance decides itself whether to charge,
1654
+ * discharge or stand still (auto mode).
1655
+ *
1656
+ * This is *not* a "no energy flow" marker: to block charging or
1657
+ * discharging, use {@link EnyoStorageScheduleDirectionEnum.Charge}
1658
+ * respectively {@link EnyoStorageScheduleDirectionEnum.Discharge}
1659
+ * with `powerW = 0`. An `Idle` entry prescribes no setpoint, so its
1660
+ * `powerW` must be `0`.
1653
1661
  */
1654
1662
  Idle = "idle"
1655
1663
  }
@@ -1679,10 +1687,11 @@ export interface EnyoStorageScheduleEntry {
1679
1687
  * Target power for this setpoint in Watts. Always non-negative —
1680
1688
  * direction is carried by {@link direction}, never by sign. A
1681
1689
  * `powerW` of `0` together with a `Charge` or `Discharge` direction
1682
- * means "hold at zero in the named direction" (effectively idle
1683
- * until the next entry); prefer the explicit
1684
- * {@link EnyoStorageScheduleDirectionEnum.Idle} direction for
1685
- * unambiguous idle slots, in which case `powerW` must also be `0`.
1690
+ * blocks that direction the battery must not charge respectively
1691
+ * discharge until the next entry. Use that to keep the battery
1692
+ * still, *not* {@link EnyoStorageScheduleDirectionEnum.Idle}, which
1693
+ * hands control back to the appliance's own logic and prescribes no
1694
+ * setpoint at all (its `powerW` must be `0`).
1686
1695
  */
1687
1696
  powerW: number;
1688
1697
  }
@@ -321,9 +321,11 @@ export var EnyoStorageScheduleModeEnum;
321
321
  * non-negative, and consumers never have to disambiguate `0` from
322
322
  * "direction-of-zero" or interpret sign conventions per integration.
323
323
  *
324
- * `Idle` is the explicit "no energy flow" markeruse it to insert
325
- * idle slots between charge / discharge periods. An entry with
326
- * `direction = Idle` must carry `powerW = 0`.
324
+ * `Idle` hands the slot back to the battery's own logic — it does *not*
325
+ * mean "no energy flow". To keep the battery still, use `Charge` or
326
+ * `Discharge` with `powerW = 0`, which blocks that direction for the
327
+ * duration of the entry. An `Idle` entry prescribes no setpoint, so it
328
+ * must carry `powerW = 0`.
327
329
  */
328
330
  export var EnyoStorageScheduleDirectionEnum;
329
331
  (function (EnyoStorageScheduleDirectionEnum) {
@@ -332,9 +334,15 @@ export var EnyoStorageScheduleDirectionEnum;
332
334
  /** Power should flow from the battery into the grid / home (discharging). */
333
335
  EnyoStorageScheduleDirectionEnum["Discharge"] = "discharge";
334
336
  /**
335
- * No energy flow the battery holds its current state-of-charge.
336
- * Use to mark idle periods between charge / discharge entries. The
337
- * entry's `powerW` must be `0`.
337
+ * Leave the slot to the battery's own logic for the duration of
338
+ * the entry the appliance decides itself whether to charge,
339
+ * discharge or stand still (auto mode).
340
+ *
341
+ * This is *not* a "no energy flow" marker: to block charging or
342
+ * discharging, use {@link EnyoStorageScheduleDirectionEnum.Charge}
343
+ * respectively {@link EnyoStorageScheduleDirectionEnum.Discharge}
344
+ * with `powerW = 0`. An `Idle` entry prescribes no setpoint, so its
345
+ * `powerW` must be `0`.
338
346
  */
339
347
  EnyoStorageScheduleDirectionEnum["Idle"] = "idle";
340
348
  })(EnyoStorageScheduleDirectionEnum || (EnyoStorageScheduleDirectionEnum = {}));
package/dist/version.d.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  /**
6
6
  * Current version of the enyo Energy App SDK.
7
7
  */
8
- export declare const SDK_VERSION = "1.8.0";
8
+ export declare const SDK_VERSION = "1.9.0";
9
9
  /**
10
10
  * Gets the current SDK version.
11
11
  * @returns The semantic version string of the SDK
package/dist/version.js CHANGED
@@ -5,7 +5,7 @@
5
5
  /**
6
6
  * Current version of the enyo Energy App SDK.
7
7
  */
8
- export const SDK_VERSION = '1.8.0';
8
+ export const SDK_VERSION = '1.9.0';
9
9
  /**
10
10
  * Gets the current SDK version.
11
11
  * @returns The semantic version string of the SDK
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enyo-energy/energy-app-sdk",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "enyo Energy App SDK",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",