@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 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 "effect";
21
- import { RtuTransportService } from "effect-modbus-rs";
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("Holding registers:", registers);
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
- Console.log(`Transport error: ${err.message}`),
39
- ModbusConnectionClosedError: (err) =>
40
- Console.log(`Connection lost: ${err.message}`),
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 "effect";
59
- import { TcpTransportService } from "effect-modbus-rs";
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("Coils:", coils);
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 "effect";
81
- import { AsciiTransportService } from "effect-modbus-rs";
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("Input registers:", registers);
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 | Options | Connection |
107
- |---------|---------|------------|
108
- | `RtuTransportService` | `{ portPath, baudRate, ... }` | `AsyncRtuTransport.open()` |
109
- | `TcpTransportService` | `{ host, port, ... }` | `AsyncTcpTransport.connect()` |
110
- | `AsciiTransportService` | `{ portPath, baudRate, ... }` | `AsyncAsciiTransport.open()` |
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
- All transport options types are re-exported from `modbus-rs`.
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 "effect";
120
- import { SerialTransportService } from "effect-modbus-rs";
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("Coils:", coils);
178
+ console.log('Coils:', coils);
127
179
  });
128
180
 
129
181
  // RTU framing
130
182
  program.pipe(
131
- Effect.provide(SerialTransportService.fromRtu({ portPath: "/dev/ttyUSB0", baudRate: 9600 })),
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 | Returns |
153
- |--------|---------|
154
- | `readHoldingRegisters({ address, quantity })` | `number[]` |
155
- | `readInputRegisters({ address, quantity })` | `number[]` |
156
- | `writeSingleRegister({ address, value })` | `void` |
157
- | `writeMultipleRegisters({ address, values })` | `void` |
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 | Returns |
163
- |--------|---------|
164
- | `readCoils({ address, quantity })` | `boolean[]` |
165
- | `writeSingleCoil({ address, value })` | `void` |
166
- | `writeMultipleCoils({ address, values })` | `void` |
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 | Returns |
172
- |--------|---------|
173
- | `readExceptionStatus()` | `number` |
174
- | `diagnostics({ subFunction, data })` | `DiagnosticsResponse` |
175
- | `readFifoQueue({ address })` | `FifoQueueResponse` |
176
- | `readFileRecord({ requests })` | `number[][]` |
177
- | `writeFileRecord({ requests })` | `void` |
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 | Meaning |
185
- |-------------|---------|
186
- | `ModbusExceptionError` | Modbus protocol exception (contains `exception` code) |
187
- | `ModbusTimeoutError` | Request timed out |
188
- | `ModbusTransportError` | Transport-level failure |
189
- | `ModbusInvalidArgumentError` | Invalid parameters |
190
- | `ModbusConnectionClosedError` | Connection lost |
191
- | `ModbusNotConnectedError` | Operation attempted before connection |
192
- | `ModbusInternalError` | Unclassified error |
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 "effect";
202
- import { RtuTransportService } from "effect-modbus-rs";
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("Coils:", coils);
467
+ console.log('Coils:', coils);
223
468
  });
224
469
 
225
470
  const mockLayer = RtuTransportService.makeMockTransport([device])({
226
- portPath: "/dev/ttyUSB0",
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 | Type | Default |
244
- |----------|------|---------|
245
- | `unitId` | `number` | required |
246
- | `coils` | `{ address, default }[]` | `[]` |
247
- | `discreteInputs` | `{ address, default }[]` | `[]` |
248
- | `holdingRegisters` | `{ address, default }[]` | `[]` |
249
- | `inputRegisters` | `{ address, default }[]` | `[]` |
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 | Command |
256
- |--------|---------|
257
- | Install | `bun install` |
258
- | Type-check | `bun run typecheck` |
259
- | Test | `bun 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
- shared-transport.ts Generic scoped transport lifecycle management
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