@flux-control/effect-modbus-rs 0.1.1 → 0.3.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 +344 -87
- package/dist/index.d.ts +85 -11
- package/dist/index.js +576 -280
- package/dist/src/AsciiTransportService.d.ts +29 -8
- package/dist/src/RtuTransportService.d.ts +29 -8
- package/dist/src/SerialModbusServerService.d.ts +5 -5
- package/dist/src/SerialTransportService.d.ts +8 -7
- package/dist/src/TcpGatewayService.d.ts +4 -4
- package/dist/src/TcpModbusServerService.d.ts +4 -4
- package/dist/src/TcpTransportService.d.ts +29 -8
- package/dist/src/WasmAsciiTransportService.d.ts +73 -0
- package/dist/src/WasmRtuTransportService.d.ts +73 -0
- package/dist/src/WasmSerialModbusServerService.d.ts +54 -0
- package/dist/src/WasmSerialPort.d.ts +27 -0
- package/dist/src/WasmSerialTransportService.d.ts +48 -0
- package/dist/src/WasmTcpServerService.d.ts +38 -0
- package/dist/src/WasmWsTransportService.d.ts +60 -0
- package/dist/src/connection.d.ts +170 -0
- package/dist/src/errors.d.ts +25 -1
- package/dist/src/mocks.d.ts +49 -9
- package/dist/src/modbus-client.d.ts +82 -23
- package/dist/src/modbus-client.wasm.test.d.ts +1 -0
- package/dist/src/resilience.test.d.ts +1 -0
- package/dist/src/retry.d.ts +227 -0
- package/dist/src/retry.test.d.ts +1 -0
- package/dist/src/shared-transport.d.ts +113 -8
- package/dist/src/shared-transport.test.d.ts +1 -0
- package/dist/src/upstream-options.test.d.ts +1 -0
- package/dist/src/wasm-mocks.test.d.ts +1 -0
- package/package.json +24 -17
package/README.md
CHANGED
|
@@ -2,12 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
**Type-safe Modbus communication via Effect-TS**, wrapping the [`modbus-rs`](https://github.com/Raghava-Ch/modbus-rs) npm bindings (Rust napi-rs under the hood).
|
|
4
4
|
|
|
5
|
+
For the complete API reference, see the [GitHub Pages documentation](https://flux-control-solutions.github.io/Effect-modbus-rs/).
|
|
6
|
+
|
|
5
7
|
Provides scoped [`Effect.Service`](https://effect.website) constructors for RTU (serial), TCP, and ASCII Modbus transports. Clients expose a typed `Effect`-based API for all standard Modbus function codes.
|
|
6
8
|
|
|
9
|
+
> This project is under active development. Its API may change before the 1.0 release.
|
|
10
|
+
|
|
7
11
|
## Install
|
|
8
12
|
|
|
9
13
|
```sh
|
|
10
|
-
bun add effect-modbus-rs
|
|
14
|
+
bun add @flux-control/effect-modbus-rs
|
|
11
15
|
```
|
|
12
16
|
|
|
13
17
|
TypeScript only while prototyping (JS consumers will be supported before 1.0).
|
|
@@ -17,8 +21,8 @@ TypeScript only while prototyping (JS consumers will be supported before 1.0).
|
|
|
17
21
|
### RTU (serial)
|
|
18
22
|
|
|
19
23
|
```ts
|
|
20
|
-
import { Console, Effect } from
|
|
21
|
-
import { RtuTransportService } from
|
|
24
|
+
import { Console, Effect } from 'effect';
|
|
25
|
+
import { RtuTransportService } from '@flux-control/effect-modbus-rs';
|
|
22
26
|
|
|
23
27
|
const program = Effect.gen(function* () {
|
|
24
28
|
const transport = yield* RtuTransportService;
|
|
@@ -28,25 +32,19 @@ const program = Effect.gen(function* () {
|
|
|
28
32
|
address: 0,
|
|
29
33
|
quantity: 10,
|
|
30
34
|
});
|
|
31
|
-
console.log(
|
|
35
|
+
console.log('Holding registers:', registers);
|
|
32
36
|
});
|
|
33
37
|
|
|
34
38
|
program.pipe(
|
|
35
39
|
Effect.catchTags({
|
|
36
40
|
ModbusTimeoutError: (err) => Console.log(`Timeout: ${err.message}`),
|
|
37
|
-
ModbusTransportError: (err) =>
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
ModbusExceptionError: (err) =>
|
|
42
|
-
Console.log(`Modbus exception ${err.exception}: ${err.message}`),
|
|
43
|
-
ModbusInvalidArgumentError: (err) =>
|
|
44
|
-
Console.log(`Invalid argument: ${err.message}`),
|
|
41
|
+
ModbusTransportError: (err) => Console.log(`Transport error: ${err.message}`),
|
|
42
|
+
ModbusConnectionClosedError: (err) => Console.log(`Connection lost: ${err.message}`),
|
|
43
|
+
ModbusExceptionError: (err) => Console.log(`Modbus exception ${err.exception}: ${err.message}`),
|
|
44
|
+
ModbusInvalidArgumentError: (err) => Console.log(`Invalid argument: ${err.message}`),
|
|
45
45
|
}),
|
|
46
46
|
Effect.catchAll((err) => Console.log(`Unhandled error: ${err.message}`)),
|
|
47
|
-
Effect.provide(
|
|
48
|
-
RtuTransportService.Default({ portPath: "/dev/ttyUSB0", baudRate: 9600 }),
|
|
49
|
-
),
|
|
47
|
+
Effect.provide(RtuTransportService.Default({ portPath: '/dev/ttyUSB0', baudRate: 9600 })),
|
|
50
48
|
Effect.scoped,
|
|
51
49
|
Effect.runPromise,
|
|
52
50
|
);
|
|
@@ -55,20 +53,18 @@ program.pipe(
|
|
|
55
53
|
### TCP
|
|
56
54
|
|
|
57
55
|
```ts
|
|
58
|
-
import { Effect } from
|
|
59
|
-
import { TcpTransportService } from
|
|
56
|
+
import { Effect } from 'effect';
|
|
57
|
+
import { TcpTransportService } from '@flux-control/effect-modbus-rs';
|
|
60
58
|
|
|
61
59
|
const program = Effect.gen(function* () {
|
|
62
60
|
const transport = yield* TcpTransportService;
|
|
63
61
|
const client = yield* transport.withClient(1);
|
|
64
62
|
const coils = yield* client.readCoils({ address: 0, quantity: 8 });
|
|
65
|
-
console.log(
|
|
63
|
+
console.log('Coils:', coils);
|
|
66
64
|
});
|
|
67
65
|
|
|
68
66
|
program.pipe(
|
|
69
|
-
Effect.provide(
|
|
70
|
-
TcpTransportService.Default({ host: "192.168.1.100", port: 502 }),
|
|
71
|
-
),
|
|
67
|
+
Effect.provide(TcpTransportService.Default({ host: '192.168.1.100', port: 502 })),
|
|
72
68
|
Effect.scoped,
|
|
73
69
|
Effect.runPromise,
|
|
74
70
|
);
|
|
@@ -77,8 +73,8 @@ program.pipe(
|
|
|
77
73
|
### ASCII
|
|
78
74
|
|
|
79
75
|
```ts
|
|
80
|
-
import { Effect } from
|
|
81
|
-
import { AsciiTransportService } from
|
|
76
|
+
import { Effect } from 'effect';
|
|
77
|
+
import { AsciiTransportService } from '@flux-control/effect-modbus-rs';
|
|
82
78
|
|
|
83
79
|
const program = Effect.gen(function* () {
|
|
84
80
|
const transport = yield* AsciiTransportService;
|
|
@@ -87,48 +83,104 @@ const program = Effect.gen(function* () {
|
|
|
87
83
|
address: 0,
|
|
88
84
|
quantity: 5,
|
|
89
85
|
});
|
|
90
|
-
console.log(
|
|
86
|
+
console.log('Input registers:', registers);
|
|
91
87
|
});
|
|
92
88
|
|
|
93
89
|
program.pipe(
|
|
94
|
-
Effect.provide(
|
|
95
|
-
AsciiTransportService.Default({ portPath: "/dev/ttyUSB0", baudRate: 9600 }),
|
|
96
|
-
),
|
|
90
|
+
Effect.provide(AsciiTransportService.Default({ portPath: '/dev/ttyUSB0', baudRate: 9600 })),
|
|
97
91
|
Effect.scoped,
|
|
98
92
|
Effect.runPromise,
|
|
99
93
|
);
|
|
100
94
|
```
|
|
101
95
|
|
|
96
|
+
### Browser / WASM (`modbus-rs/web`)
|
|
97
|
+
|
|
98
|
+
`modbus-rs` ships its browser bindings through the `modbus-rs/web` WASM module. This package loads that module dynamically and exposes the same scoped, typed `Effect` client API as its native transports. Since browsers can't open raw TCP or serial connections directly, there are two browser-specific transports:
|
|
99
|
+
|
|
100
|
+
- **`WasmWsTransportService`** — Modbus TCP over a WebSocket-to-TCP gateway (e.g. the `modbus-gateway` application).
|
|
101
|
+
- **`WasmRtuTransportService`** / **`WasmAsciiTransportService`** — Modbus RTU/ASCII over the [Web Serial API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API), via `WasmSerialTransportService.fromRtu` / `.fromAscii`.
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
import { Console, Effect } from 'effect';
|
|
105
|
+
import { WasmWsTransportService } from '@flux-control/effect-modbus-rs';
|
|
106
|
+
|
|
107
|
+
const program = Effect.gen(function* () {
|
|
108
|
+
const transport = yield* WasmWsTransportService;
|
|
109
|
+
const client = yield* transport.withClient(1);
|
|
110
|
+
const registers = yield* client.readHoldingRegisters({ address: 0, quantity: 10 });
|
|
111
|
+
console.log('Holding registers:', registers);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
program.pipe(
|
|
115
|
+
Effect.provide(WasmWsTransportService.Default({ wsUrl: 'ws://localhost:8080' })),
|
|
116
|
+
Effect.scoped,
|
|
117
|
+
Effect.runPromise,
|
|
118
|
+
);
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Web Serial requires a user-granted port handle. **`requestSerialPort()` must be called synchronously from within a user-gesture event handler** (e.g. a button click) — this is a Web Serial API / browser security requirement, not a library restriction:
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
import { Effect, Layer } from 'effect';
|
|
125
|
+
import { requestSerialPort, WasmRtuTransportService } from '@flux-control/effect-modbus-rs';
|
|
126
|
+
|
|
127
|
+
connectButton.addEventListener('click', () => {
|
|
128
|
+
Effect.runPromise(
|
|
129
|
+
Effect.gen(function* () {
|
|
130
|
+
const port = yield* requestSerialPort();
|
|
131
|
+
yield* program.pipe(
|
|
132
|
+
Effect.provide(WasmRtuTransportService.Default({ port, baudRate: 19200 })),
|
|
133
|
+
Effect.scoped,
|
|
134
|
+
);
|
|
135
|
+
}),
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
See `examples/wasm/` for a real, runnable Vite app exercising both transports in an actual browser (`cd examples/wasm && npm install && npm run dev`).
|
|
141
|
+
|
|
142
|
+
#### Browser server (experimental)
|
|
143
|
+
|
|
144
|
+
`wasmWsServerLayer` and `wasmSerialRtuServerLayer` / `wasmSerialAsciiServerLayer` wrap `modbus-rs`'s experimental browser server bindings — same `ServerHandlers` callback shape as the native servers below. Two things differ from native:
|
|
145
|
+
|
|
146
|
+
- Unlike native servers, the WASM server doesn't start serving on bind — these layers fork the required `serve()` loop into the layer's scope automatically, so usage looks the same as the native `tcpServerLayer`.
|
|
147
|
+
- For the serial variants, `options.serialPort` comes from your own app's `navigator.serial.requestPort()` call (not from this package's `requestSerialPort()`, which returns a different wrapper type used only by the client transports).
|
|
148
|
+
|
|
149
|
+
Not demonstrated in `examples/wasm/` (see that app's README) — the same `import { wasmWsServerLayer } from "@flux-control/effect-modbus-rs"` pattern applies.
|
|
150
|
+
|
|
102
151
|
## Transports
|
|
103
152
|
|
|
104
153
|
Each transport is a scoped `Effect.Service`. You provide it with `Effect.provide`, and the connection is opened on service access and closed when the scope ends.
|
|
105
154
|
|
|
106
|
-
| Service
|
|
107
|
-
|
|
108
|
-
| `RtuTransportService`
|
|
109
|
-
| `TcpTransportService`
|
|
110
|
-
| `AsciiTransportService`
|
|
155
|
+
| Service | Options | Connection |
|
|
156
|
+
| ------------------------------------- | ------------------------------ | ----------------------------- |
|
|
157
|
+
| `RtuTransportService` | `{ portPath, baudRate, ... }` | `AsyncRtuTransport.open()` |
|
|
158
|
+
| `TcpTransportService` | `{ host, port, ... }` | `AsyncTcpTransport.connect()` |
|
|
159
|
+
| `AsciiTransportService` | `{ portPath, baudRate, ... }` | `AsyncAsciiTransport.open()` |
|
|
160
|
+
| `WasmWsTransportService` (browser) | `{ wsUrl, requestTimeoutMs? }` | `WasmWsTransport.connect()` |
|
|
161
|
+
| `WasmRtuTransportService` (browser) | `{ port, baudRate, ... }` | `WasmRtuTransport.open()` |
|
|
162
|
+
| `WasmAsciiTransportService` (browser) | `{ port, baudRate, ... }` | `WasmAsciiTransport.open()` |
|
|
111
163
|
|
|
112
|
-
|
|
164
|
+
Browser transport option types are re-exported from `modbus-rs/web` unchanged. The native ones are narrowed — `RtuTransportOpenOptions`, `AsciiTransportOpenOptions`, and `TcpTransportOpenOptions` are their `modbus-rs` counterparts minus the retry knobs, for the reasons in [Why the upstream retry knobs are withheld](#why-the-upstream-retry-knobs-are-withheld).
|
|
113
165
|
|
|
114
166
|
### Abstract serial transport
|
|
115
167
|
|
|
116
168
|
`SerialTransportService` is a transport-agnostic tag that can be backed by either RTU or ASCII framing — useful when writing code that doesn't need to commit to a specific serial protocol. Provide it with `fromRtu` or `fromAscii`:
|
|
117
169
|
|
|
118
170
|
```ts
|
|
119
|
-
import { Console, Effect } from
|
|
120
|
-
import { SerialTransportService } from
|
|
171
|
+
import { Console, Effect } from 'effect';
|
|
172
|
+
import { SerialTransportService } from '@flux-control/effect-modbus-rs';
|
|
121
173
|
|
|
122
174
|
const program = Effect.gen(function* () {
|
|
123
175
|
const transport = yield* SerialTransportService;
|
|
124
176
|
const client = yield* transport.withClient(1);
|
|
125
177
|
const coils = yield* client.readCoils({ address: 0, quantity: 2 });
|
|
126
|
-
console.log(
|
|
178
|
+
console.log('Coils:', coils);
|
|
127
179
|
});
|
|
128
180
|
|
|
129
181
|
// RTU framing
|
|
130
182
|
program.pipe(
|
|
131
|
-
Effect.provide(SerialTransportService.fromRtu({ portPath:
|
|
183
|
+
Effect.provide(SerialTransportService.fromRtu({ portPath: '/dev/ttyUSB0', baudRate: 9600 })),
|
|
132
184
|
Effect.scoped,
|
|
133
185
|
Effect.runPromise,
|
|
134
186
|
);
|
|
@@ -149,57 +201,250 @@ It also supports `makeMockTransport` for testing:
|
|
|
149
201
|
|
|
150
202
|
### Registers
|
|
151
203
|
|
|
152
|
-
| Method
|
|
153
|
-
|
|
154
|
-
| `readHoldingRegisters({ address, quantity })`
|
|
155
|
-
| `readInputRegisters({ address, quantity })`
|
|
156
|
-
| `writeSingleRegister({ address, value })`
|
|
157
|
-
| `writeMultipleRegisters({ address, values })`
|
|
204
|
+
| Method | Returns |
|
|
205
|
+
| -------------------------------------------------------------------------------------- | ---------- |
|
|
206
|
+
| `readHoldingRegisters({ address, quantity })` | `number[]` |
|
|
207
|
+
| `readInputRegisters({ address, quantity })` | `number[]` |
|
|
208
|
+
| `writeSingleRegister({ address, value })` | `void` |
|
|
209
|
+
| `writeMultipleRegisters({ address, values })` | `void` |
|
|
158
210
|
| `readWriteMultipleRegisters({ readAddress, readQuantity, writeAddress, writeValues })` | `number[]` |
|
|
159
211
|
|
|
160
212
|
### Coils / discrete inputs
|
|
161
213
|
|
|
162
|
-
| Method
|
|
163
|
-
|
|
164
|
-
| `readCoils({ address, quantity })`
|
|
165
|
-
| `writeSingleCoil({ address, value })`
|
|
166
|
-
| `writeMultipleCoils({ address, values })`
|
|
214
|
+
| Method | Returns |
|
|
215
|
+
| ------------------------------------------- | ----------- |
|
|
216
|
+
| `readCoils({ address, quantity })` | `boolean[]` |
|
|
217
|
+
| `writeSingleCoil({ address, value })` | `void` |
|
|
218
|
+
| `writeMultipleCoils({ address, values })` | `void` |
|
|
167
219
|
| `readDiscreteInputs({ address, quantity })` | `boolean[]` |
|
|
168
220
|
|
|
169
221
|
### Diagnostics & file access
|
|
170
222
|
|
|
171
|
-
| Method
|
|
172
|
-
|
|
173
|
-
| `readExceptionStatus()`
|
|
174
|
-
| `diagnostics({ subFunction, data })`
|
|
175
|
-
| `readFifoQueue({ address })`
|
|
176
|
-
| `readFileRecord({ requests })`
|
|
177
|
-
| `writeFileRecord({ requests })`
|
|
223
|
+
| Method | Returns |
|
|
224
|
+
| ---------------------------------------------------------- | ------------------------------ |
|
|
225
|
+
| `readExceptionStatus()` | `number` |
|
|
226
|
+
| `diagnostics({ subFunction, data })` | `DiagnosticsResponse` |
|
|
227
|
+
| `readFifoQueue({ address })` | `FifoQueueResponse` |
|
|
228
|
+
| `readFileRecord({ requests })` | `number[][]` |
|
|
229
|
+
| `writeFileRecord({ requests })` | `void` |
|
|
178
230
|
| `readDeviceIdentification({ readDeviceIdCode, objectId })` | `DeviceIdentificationResponse` |
|
|
179
231
|
|
|
180
232
|
## Error handling
|
|
181
233
|
|
|
182
234
|
Errors from the underlying Rust layer are mapped to typed `Effect` errors via `Data.TaggedError`:
|
|
183
235
|
|
|
184
|
-
| Error class
|
|
185
|
-
|
|
186
|
-
| `ModbusExceptionError`
|
|
187
|
-
| `ModbusTimeoutError`
|
|
188
|
-
| `ModbusTransportError`
|
|
189
|
-
| `ModbusInvalidArgumentError`
|
|
190
|
-
| `ModbusConnectionClosedError` | Connection lost
|
|
191
|
-
| `ModbusNotConnectedError`
|
|
192
|
-
| `ModbusInternalError`
|
|
236
|
+
| Error class | Meaning |
|
|
237
|
+
| ----------------------------- | ----------------------------------------------------- |
|
|
238
|
+
| `ModbusExceptionError` | Modbus protocol exception (contains `exception` code) |
|
|
239
|
+
| `ModbusTimeoutError` | Request timed out |
|
|
240
|
+
| `ModbusTransportError` | Transport-level failure |
|
|
241
|
+
| `ModbusInvalidArgumentError` | Invalid parameters |
|
|
242
|
+
| `ModbusConnectionClosedError` | Connection lost |
|
|
243
|
+
| `ModbusNotConnectedError` | Operation attempted before connection |
|
|
244
|
+
| `ModbusInternalError` | Unclassified error |
|
|
193
245
|
|
|
194
246
|
Handle with `Effect.catchTags`. The `ModbusError` union type covers all seven variants.
|
|
195
247
|
|
|
248
|
+
## Resilience: retries, reconnection, and circuit breaking
|
|
249
|
+
|
|
250
|
+
> **These are application-level policies, and they are opt-in.**
|
|
251
|
+
>
|
|
252
|
+
> They are also the _only_ retries in play. The **transport-level** knobs the underlying `modbus-rs` library offers — `retryAttempts`, `retryDelayMs`, and `retryBackoffStrategy` — are **not accepted** by any transport constructor here. Passing one is a type error. See [Why the upstream retry knobs are withheld](#why-the-upstream-retry-knobs-are-withheld).
|
|
253
|
+
|
|
254
|
+
Resilience belongs to the **transport**, not to call sites. Attach a policy where the transport is created and every client derived from it carries it:
|
|
255
|
+
|
|
256
|
+
```ts
|
|
257
|
+
const layer = TcpTransportService.Default({
|
|
258
|
+
host: '192.168.1.50',
|
|
259
|
+
port: 502,
|
|
260
|
+
retry: RetryPolicies.tcp(), // applied to every operation
|
|
261
|
+
reconnect: {}, // supervised reconnect + circuit breaker
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// call sites never mention retries
|
|
265
|
+
const client = yield * transport.withClient(1);
|
|
266
|
+
yield * client.readHoldingRegisters({ address: 0, quantity: 10 });
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
With neither option set, a transport behaves exactly as it always has: one attempt per operation, reconnection only when you ask for it.
|
|
270
|
+
|
|
271
|
+
### Templates
|
|
272
|
+
|
|
273
|
+
| Template | Shape | For |
|
|
274
|
+
| ---------------------------- | ------------------------------------------------------- | ------------------------------------- |
|
|
275
|
+
| `RetryPolicies.none()` | 1 attempt | Opting out of a wider policy |
|
|
276
|
+
| `RetryPolicies.serial()` | 3 retries, 50 ms base, ×2, 1 s ceiling | RS-232/485 — collisions, noise bursts |
|
|
277
|
+
| `RetryPolicies.tcp()` | 4 retries, 100 ms base, ×2, 5 s ceiling | Modbus/TCP — sockets and gateways |
|
|
278
|
+
| `RetryPolicies.persistent()` | 10 retries, 250 ms base, ×2, 30 s ceiling, 5 min budget | Long-running background polling |
|
|
279
|
+
|
|
280
|
+
All four jitter their delays — see [Backoff and jitter](#backoff-and-jitter) — and none retry `ModbusInvalidArgumentError` or a deterministic exception code.
|
|
281
|
+
|
|
282
|
+
Every template is a factory taking overrides, so it doubles as a starting point. Overrides merge into the template rather than replacing it wholesale:
|
|
283
|
+
|
|
284
|
+
```ts
|
|
285
|
+
RetryPolicies.serial({
|
|
286
|
+
maxRetries: 6,
|
|
287
|
+
errors: { ModbusTimeoutError: { baseDelay: '80 millis' } },
|
|
288
|
+
});
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
`makeRetryPolicy(options)` builds one from scratch with the same options.
|
|
292
|
+
|
|
293
|
+
### Overriding per client and per operation
|
|
294
|
+
|
|
295
|
+
One bus often hosts device types that need different logic. A per-client policy **replaces** the transport's, so overrides can never multiply attempt counts:
|
|
296
|
+
|
|
297
|
+
```ts
|
|
298
|
+
const meter = yield * transport.withClient(1, { retry: RetryPolicies.serial() });
|
|
299
|
+
const plc = yield * transport.withClient(2, { retry: RetryPolicies.serial({ maxRetries: 8 }) });
|
|
300
|
+
const legacy = yield * transport.withClient(3, { retry: RetryPolicies.none() });
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
Clients built for the same unit ID under different policies share one underlying connection.
|
|
304
|
+
|
|
305
|
+
`client.withRetry(policy)` does the same for a single operation:
|
|
306
|
+
|
|
307
|
+
```ts
|
|
308
|
+
yield * client.withRetry(RetryPolicies.none()).writeSingleCoil({ address: 0, value });
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
Resolution order is **per-operation → per-client → transport → none**. First match wins; the others are discarded, not combined.
|
|
312
|
+
|
|
313
|
+
#### Replacing vs. wrapping
|
|
314
|
+
|
|
315
|
+
Two things look alike at a call site — both read as "attach a policy here" — but behave differently, and the difference is worth internalising:
|
|
316
|
+
|
|
317
|
+
| Form | Effect on the policy already in force |
|
|
318
|
+
| ------------------------------- | ------------------------------------- |
|
|
319
|
+
| `withClient(unitId, { retry })` | **Replaces** it |
|
|
320
|
+
| `client.withRetry(policy)` | **Replaces** it |
|
|
321
|
+
| `.pipe(retryModbus(policy))` | **Wraps** it — the two nest |
|
|
322
|
+
|
|
323
|
+
The deciding factor is whether the policy goes _through_ the client or _around_ it. The first two are resolved inside the client when it is built, so the previous policy is never applied. `retryModbus` is a free function piped around an effect the client has **already** wrapped in its own retry — nothing in that path can see the inner policy, so both run and the attempt counts multiply.
|
|
324
|
+
|
|
325
|
+
Concretely, against a transport policy of `maxRetries: 2` (3 attempts):
|
|
326
|
+
|
|
327
|
+
```ts
|
|
328
|
+
transport.withClient(1, { retry: fast(3) }) // 4 attempts (replaced)
|
|
329
|
+
client.withRetry(fast(4)).readHoldingRegisters(...) // 5 attempts (replaced)
|
|
330
|
+
client.readHoldingRegisters(...).pipe(retryModbus(fast(3))) // 12 attempts (3 × 4)
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
That last form is only correct over a `RetryPolicies.none()` client — see [Retrying a transaction](#retrying-a-transaction).
|
|
334
|
+
|
|
335
|
+
### Error-aware by construction
|
|
336
|
+
|
|
337
|
+
Retrying is only correct for failures that can plausibly resolve themselves, so the policy decides per error:
|
|
338
|
+
|
|
339
|
+
| Error | Retried by default |
|
|
340
|
+
| ----------------------------- | ------------------------------------------------------------------ |
|
|
341
|
+
| `ModbusTimeoutError` | yes — slow turnaround, bus contention |
|
|
342
|
+
| `ModbusTransportError` | yes — framing/CRC corruption |
|
|
343
|
+
| `ModbusConnectionClosedError` | yes — and hands the link to the supervisor |
|
|
344
|
+
| `ModbusCircuitOpenError` | yes — refused without touching the bus, so it is cheap to wait out |
|
|
345
|
+
| `ModbusExceptionError` | only for codes `5`, `6`, `10`, `11` (busy / gateway) |
|
|
346
|
+
| `ModbusInvalidArgumentError` | no — the answer will not change |
|
|
347
|
+
| `ModbusNotConnectedError` | no |
|
|
348
|
+
| `ModbusInternalError` | no |
|
|
349
|
+
|
|
350
|
+
Any of these can be switched off (`errors: { ModbusTimeoutError: false }`), switched on, or given their own backoff curve (`errors: { ModbusConnectionClosedError: { baseDelay: '250 millis' } }`). The retry budget is shared across categories — only the delay curve is per-error — so a mixed failure sequence still stops after `maxRetries`.
|
|
351
|
+
|
|
352
|
+
### Backoff and jitter
|
|
353
|
+
|
|
354
|
+
Delays follow `min(maxDelay, baseDelay × factor ** retryIndex)`, then get jittered.
|
|
355
|
+
|
|
356
|
+
**Jitter is on by default** — for `makeRetryPolicy()` and for every template, none of which opts out. Each delay is multiplied by a random factor so a fleet of pollers does not re-hit a recovering device in lockstep:
|
|
357
|
+
|
|
358
|
+
| `jitter` | Delay |
|
|
359
|
+
| ---------------------------- | -------------------------------------------------------- |
|
|
360
|
+
| omitted, or `true` (default) | ±20% — Effect's `0.8 – 1.2` multiplier range |
|
|
361
|
+
| `false` | exact, unrandomised delays — useful for assertable tests |
|
|
362
|
+
| `{ min: 0.5, max: 1.5 }` | custom multiplier range |
|
|
363
|
+
|
|
364
|
+
So `RetryPolicies.tcp()` waits roughly 80–120 ms before its first retry, not exactly 100 ms.
|
|
365
|
+
|
|
366
|
+
### Supervised reconnection and the circuit breaker
|
|
367
|
+
|
|
368
|
+
Passing `reconnect` hands reconnection to a supervisor fiber owned by the transport — **one reconnect for the whole application**, however many fibers were in flight when the link dropped:
|
|
369
|
+
|
|
370
|
+
```ts
|
|
371
|
+
TcpTransportService.Default({
|
|
372
|
+
host,
|
|
373
|
+
port,
|
|
374
|
+
reconnect: {
|
|
375
|
+
policy: RetryPolicies.tcp(), // how reconnect attempts are spaced
|
|
376
|
+
resetAfter: '30 seconds', // how long the circuit stays open before probing
|
|
377
|
+
triggerOn: ['ModbusConnectionClosedError', 'ModbusTransportError'],
|
|
378
|
+
},
|
|
379
|
+
});
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
While the link is being re-established, operations are refused with `ModbusCircuitOpenError` instead of queueing requests onto a dead bus. Because that error is retryable by default and costs nothing on the wire, a polling loop with a generous policy simply rides out the outage; one with a short budget fails fast and lets the caller decide.
|
|
383
|
+
|
|
384
|
+
State transitions are published on `transport.connectionState`:
|
|
385
|
+
|
|
386
|
+
| State | Meaning |
|
|
387
|
+
| -------------- | -------------------------------------------------------------------------------------- |
|
|
388
|
+
| `Disconnected` | Never opened, or closed. The next operation opens it lazily. |
|
|
389
|
+
| `Connected` | Usable. |
|
|
390
|
+
| `Reconnecting` | Supervisor is re-establishing the link. Operations refused. |
|
|
391
|
+
| `Down` | Attempts exhausted; waiting out `resetAfter` before probing again. Operations refused. |
|
|
392
|
+
|
|
393
|
+
```ts
|
|
394
|
+
yield *
|
|
395
|
+
Stream.runForEach(transport.connectionState.changes, (state) =>
|
|
396
|
+
Console.log(`link: ${state._tag}`),
|
|
397
|
+
);
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
### Retrying a transaction
|
|
401
|
+
|
|
402
|
+
`retryModbus(policy)` remains exported for the one case the transport cannot express: driving a **compound** operation as a unit, where retrying individual frames would be wrong.
|
|
403
|
+
|
|
404
|
+
```ts
|
|
405
|
+
const client = yield * transport.withClient(1, { retry: RetryPolicies.none() });
|
|
406
|
+
|
|
407
|
+
yield *
|
|
408
|
+
Effect.gen(function* () {
|
|
409
|
+
const current = yield* client.readHoldingRegisters({ address: 0, quantity: 2 });
|
|
410
|
+
yield* client.writeMultipleRegisters({ address: 0, values: bump(current) });
|
|
411
|
+
}).pipe(retryModbus(RetryPolicies.tcp()));
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
Take a `RetryPolicies.none()` client first. Unlike `withClient({ retry })` and `client.withRetry()`, which replace the policy in force, `retryModbus` wraps whatever the client is already doing — so over a policied client the two nest and the attempt counts multiply. See [Replacing vs. wrapping](#replacing-vs-wrapping).
|
|
415
|
+
|
|
416
|
+
See `examples/retry-policies.ts` for a runnable walkthrough.
|
|
417
|
+
|
|
418
|
+
### Why the upstream retry knobs are withheld
|
|
419
|
+
|
|
420
|
+
`modbus-rs` exposes `retryAttempts`, `retryDelayMs`, and `retryBackoffStrategy` on its transport options. This package removes all three from every transport constructor, so setting one is a compile error rather than a documented hazard:
|
|
421
|
+
|
|
422
|
+
```ts
|
|
423
|
+
TcpTransportService.Default({ host, port, retryAttempts: 3 });
|
|
424
|
+
// ^^^^^^^^^^^^^ Object literal may only specify
|
|
425
|
+
// known properties, and 'retryAttempts' does not
|
|
426
|
+
// exist in type 'TcpTransportOpenOptions & …'
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
They are withheld rather than merely discouraged because enabling them is never the right call under this design:
|
|
430
|
+
|
|
431
|
+
- **They retry below the Effect boundary.** A failure they paper over never reaches your policy, the circuit breaker, or your logs. The caller sees one slow success instead of several failures and a recovery, and any caller-side `Effect.timeout` is measuring inflated time.
|
|
432
|
+
- **They reconnect.** Upstream re-establishes the link inline and replays in-flight requests after it, which races the single supervisor fiber that is supposed to own reconnection for the whole transport.
|
|
433
|
+
- **They multiply.** Neither layer knows about the other, so attempt counts compound and the two backoff curves interleave.
|
|
434
|
+
- **`retryDelayMs` is flat and unjittered** — exactly the lockstep-collision pattern `RetryPolicies.serial()` exists to break up on a shared RS-485 segment.
|
|
435
|
+
- **`retryBackoffStrategy` does nothing.** It is documented upstream as inert and reserved for future implementation, so `'exponential'` silently gets you a flat delay.
|
|
436
|
+
|
|
437
|
+
Use `retry` and `reconnect` on the transport instead. If you genuinely need frame-level resends, construct a raw `modbus-rs` client directly, where that trade-off is explicit rather than hidden under an Effect service.
|
|
438
|
+
|
|
439
|
+
The narrowed option types are exported as `RtuTransportOpenOptions`, `AsciiTransportOpenOptions`, and `TcpTransportOpenOptions`, alongside the generic `WithoutUpstreamRetry<T>` and the `UpstreamRetryOptionKey` union.
|
|
440
|
+
|
|
196
441
|
## Testing with mocks
|
|
197
442
|
|
|
198
443
|
Each transport service provides a `makeMockTransport(devices)` static method that returns an in-memory mock `Layer` — no serial port or network required.
|
|
199
444
|
|
|
200
445
|
```ts
|
|
201
|
-
import { Console, Effect } from
|
|
202
|
-
import { RtuTransportService } from
|
|
446
|
+
import { Console, Effect } from 'effect';
|
|
447
|
+
import { RtuTransportService } from '@flux-control/effect-modbus-rs';
|
|
203
448
|
|
|
204
449
|
const device = {
|
|
205
450
|
unitId: 1,
|
|
@@ -219,19 +464,15 @@ const program = Effect.gen(function* () {
|
|
|
219
464
|
const transport = yield* RtuTransportService;
|
|
220
465
|
const client = yield* transport.withClient(1);
|
|
221
466
|
const coils = yield* client.readCoils({ address: 0, quantity: 2 });
|
|
222
|
-
console.log(
|
|
467
|
+
console.log('Coils:', coils);
|
|
223
468
|
});
|
|
224
469
|
|
|
225
470
|
const mockLayer = RtuTransportService.makeMockTransport([device])({
|
|
226
|
-
portPath:
|
|
471
|
+
portPath: '/dev/ttyUSB0',
|
|
227
472
|
baudRate: 9600,
|
|
228
473
|
});
|
|
229
474
|
|
|
230
|
-
program.pipe(
|
|
231
|
-
Effect.provide(mockLayer),
|
|
232
|
-
Effect.scoped,
|
|
233
|
-
Effect.runPromise,
|
|
234
|
-
);
|
|
475
|
+
program.pipe(Effect.provide(mockLayer), Effect.scoped, Effect.runPromise);
|
|
235
476
|
```
|
|
236
477
|
|
|
237
478
|
The mock factory is identical for all three transports; swap `RtuTransportService` for `TcpTransportService` or `AsciiTransportService` and adjust the options shape accordingly — each exposes a static `makeMockTransport` method.
|
|
@@ -240,23 +481,23 @@ See `examples/rtu-mock.ts`, `examples/tcp-mock.ts`, and `examples/ascii-mock.ts`
|
|
|
240
481
|
|
|
241
482
|
### Slave device schema
|
|
242
483
|
|
|
243
|
-
| Property
|
|
244
|
-
|
|
245
|
-
| `unitId`
|
|
246
|
-
| `coils`
|
|
247
|
-
| `discreteInputs`
|
|
248
|
-
| `holdingRegisters` | `{ address, default }[]` | `[]`
|
|
249
|
-
| `inputRegisters`
|
|
484
|
+
| Property | Type | Default |
|
|
485
|
+
| ------------------ | ------------------------ | -------- |
|
|
486
|
+
| `unitId` | `number` | required |
|
|
487
|
+
| `coils` | `{ address, default }[]` | `[]` |
|
|
488
|
+
| `discreteInputs` | `{ address, default }[]` | `[]` |
|
|
489
|
+
| `holdingRegisters` | `{ address, default }[]` | `[]` |
|
|
490
|
+
| `inputRegisters` | `{ address, default }[]` | `[]` |
|
|
250
491
|
|
|
251
492
|
Coil/default values default to `false` if omitted at the address level; register values default to `0`. Reads beyond the highest configured address produce a `ModbusInvalidArgumentError`.
|
|
252
493
|
|
|
253
494
|
## Development
|
|
254
495
|
|
|
255
|
-
| Action
|
|
256
|
-
|
|
257
|
-
| Install
|
|
258
|
-
| Type-check
|
|
259
|
-
| Test
|
|
496
|
+
| Action | Command |
|
|
497
|
+
| ----------- | ---------------------------- |
|
|
498
|
+
| Install | `bun install` |
|
|
499
|
+
| Type-check | `bun run typecheck` |
|
|
500
|
+
| Test | `bun test` |
|
|
260
501
|
| Run example | `bun run examples/<name>.ts` |
|
|
261
502
|
|
|
262
503
|
No build step — `noEmit` is on; Bun runs `.ts` directly.
|
|
@@ -266,13 +507,25 @@ No build step — `noEmit` is on; Bun runs `.ts` directly.
|
|
|
266
507
|
```
|
|
267
508
|
src/
|
|
268
509
|
errors.ts — Data.TaggedError types + toModbusError converter
|
|
269
|
-
modbus-client.ts — EffectModbusClient interface + factory
|
|
510
|
+
modbus-client.ts — EffectModbusClient interface + factory (native + WASM)
|
|
270
511
|
mocks.ts — Schema-validated mock transport + slave device definitions
|
|
271
|
-
|
|
512
|
+
connection.ts — Connection state machine, reconnect supervisor, circuit breaker
|
|
513
|
+
retry.ts — Opt-in retry policies (backoff, jitter, per-error rules)
|
|
514
|
+
shared-transport.ts — Generic scoped transport lifecycle management, WithoutUpstreamRetry
|
|
272
515
|
RtuTransportService.ts — Scoped Effect.Service wrapping AsyncRtuTransport
|
|
273
516
|
TcpTransportService.ts — Scoped Effect.Service wrapping AsyncTcpTransport
|
|
274
517
|
AsciiTransportService.ts — Scoped Effect.Service wrapping AsyncAsciiTransport
|
|
275
518
|
SerialTransportService.ts — Abstract serial transport (RTU/ASCII) tag
|
|
519
|
+
TcpModbusServerService.ts — tcpServerLayer
|
|
520
|
+
SerialModbusServerService.ts — serialRtuServerLayer / serialAsciiServerLayer
|
|
521
|
+
TcpGatewayService.ts — tcpGatewayLayer
|
|
522
|
+
WasmSerialPort.ts — requestSerialPort() Effect helper (browser, user-gesture gated)
|
|
523
|
+
WasmWsTransportService.ts — Scoped Effect.Service wrapping WasmWsTransport (browser, WS gateway)
|
|
524
|
+
WasmRtuTransportService.ts — Scoped Effect.Service wrapping WasmRtuTransport (browser, Web Serial RTU)
|
|
525
|
+
WasmAsciiTransportService.ts — Scoped Effect.Service wrapping WasmAsciiTransport (browser, Web Serial ASCII)
|
|
526
|
+
WasmSerialTransportService.ts — Abstract browser serial transport (RTU/ASCII) tag
|
|
527
|
+
WasmTcpServerService.ts — wasmWsServerLayer (experimental)
|
|
528
|
+
WasmSerialModbusServerService.ts — wasmSerialRtuServerLayer / wasmSerialAsciiServerLayer (experimental)
|
|
276
529
|
examples/
|
|
277
530
|
rtu-basic.ts — RTU usage pattern
|
|
278
531
|
tcp-basic.ts — TCP usage pattern
|
|
@@ -281,8 +534,12 @@ examples/
|
|
|
281
534
|
rtu-mock.ts — RTU with in-memory mock
|
|
282
535
|
tcp-mock.ts — TCP with in-memory mock (multi-device)
|
|
283
536
|
ascii-mock.ts — ASCII with in-memory mock (error-case)
|
|
537
|
+
retry-policies.ts — Transport-owned resilience: policies, overrides, transactions
|
|
284
538
|
tcp-polling-stream.ts — TCP polling, reconnect, and stream
|
|
285
539
|
tcp-finalizer-reset.ts — TCP scope finalizer reset demo
|
|
540
|
+
tcp-server.ts — TCP server example
|
|
541
|
+
serial-server.ts — Serial RTU server example
|
|
542
|
+
wasm/ — Standalone runnable Vite app for the browser transports (own README, own npm project)
|
|
286
543
|
index.ts — Re-exports public API
|
|
287
544
|
```
|
|
288
545
|
|