@flux-control/effect-modbus-rs 0.8.0 → 0.8.1
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 +498 -831
- package/dist/index.d.ts +56 -113
- package/dist/index.js +234 -116
- package/dist/src/AsciiTransportService.d.ts +15 -14
- package/dist/src/RtuTransportService.d.ts +15 -14
- package/dist/src/SerialModbusServerService.d.ts +3 -2
- package/dist/src/SerialTransportService.d.ts +8 -2
- package/dist/src/TcpGatewayService.d.ts +2 -1
- package/dist/src/TcpModbusServerService.d.ts +2 -1
- package/dist/src/TcpTransportService.d.ts +6 -5
- package/dist/src/WasmAsciiTransportService.d.ts +13 -7
- package/dist/src/WasmRtuTransportService.d.ts +13 -7
- package/dist/src/WasmSerialModbusServerService.d.ts +4 -2
- package/dist/src/WasmSerialPort.d.ts +6 -4
- package/dist/src/WasmSerialTransportService.d.ts +9 -2
- package/dist/src/WasmTcpServerService.d.ts +2 -1
- package/dist/src/WasmWsTransportService.d.ts +9 -5
- package/dist/src/batching-client.d.ts +61 -28
- package/dist/src/connection.d.ts +6 -2
- package/dist/src/errors.d.ts +11 -12
- package/dist/src/mocks.d.ts +3 -2
- package/dist/src/modbus-client.d.ts +14 -10
- package/dist/src/read-debouncer.d.ts +8 -22
- package/dist/src/register-cache.d.ts +1 -7
- package/dist/src/register-plan.d.ts +2 -12
- package/dist/src/retry.d.ts +1 -0
- package/dist/src/serial-port-path.d.ts +21 -0
- package/dist/src/shared-transport.d.ts +25 -5
- package/dist/src/span-attributes.d.ts +1 -1
- package/dist/src/write-debouncer.d.ts +15 -49
- package/dist/test/native-transport.test.d.ts +1 -0
- package/dist/test/serial-port-path.test.d.ts +1 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,134 +1,114 @@
|
|
|
1
1
|
# Effect-modbus-rs
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Typed Effect 4 services for Modbus communication through the
|
|
4
|
+
[`modbus-rs`](https://github.com/Raghava-Ch/modbus-rs) Rust bindings.
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
The package provides native TCP, RTU, and ASCII transports.
|
|
7
|
+
Browser transports use WebSocket or Web Serial through `modbus-rs/web`.
|
|
8
|
+
Each transport owns its connection, clients, and optional batching state within a scope.
|
|
6
9
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
> This project is under active development. Its API may change before the 1.0 release.
|
|
10
|
+
For the API reference, see the [GitHub Pages documentation](https://flux-control-solutions.github.io/Effect-modbus-rs/).
|
|
11
|
+
The API can change before version 1.0.
|
|
10
12
|
|
|
11
13
|
## Install
|
|
12
14
|
|
|
13
15
|
```sh
|
|
14
|
-
bun add @flux-control/effect-modbus-rs
|
|
16
|
+
bun add @flux-control/effect-modbus-rs effect@4.0.0-rc.118
|
|
15
17
|
```
|
|
16
18
|
|
|
17
|
-
|
|
19
|
+
The current package requires Effect `^4.0.0-rc.118` as a peer dependency.
|
|
20
|
+
It depends on `modbus-rs@0.16.3`.
|
|
21
|
+
The published package exports JavaScript and TypeScript declarations from `dist/`.
|
|
18
22
|
|
|
19
23
|
## Quick start
|
|
20
24
|
|
|
21
|
-
### RTU (serial)
|
|
22
|
-
|
|
23
25
|
```ts
|
|
24
26
|
import { Console, Effect } from 'effect';
|
|
25
|
-
import { RtuTransportService } from '@flux-control/effect-modbus-rs';
|
|
26
|
-
|
|
27
|
-
const program = Effect.gen(function* () {
|
|
28
|
-
const transport = yield* RtuTransportService;
|
|
29
|
-
const client = yield* transport.withClient(1);
|
|
30
|
-
|
|
31
|
-
const registers = yield* client.readHoldingRegisters({
|
|
32
|
-
address: 0,
|
|
33
|
-
quantity: 10,
|
|
34
|
-
});
|
|
35
|
-
console.log('Holding registers:', registers);
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
program.pipe(
|
|
39
|
-
Effect.catchTags({
|
|
40
|
-
ModbusTimeoutError: (err) => Console.log(`Timeout: ${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
|
-
}),
|
|
46
|
-
Effect.catch((err) => Console.log(`Unhandled error: ${err.message}`)),
|
|
47
|
-
Effect.provide(RtuTransportService.make({ portPath: '/dev/ttyUSB0', baudRate: 9600 })),
|
|
48
|
-
Effect.scoped,
|
|
49
|
-
Effect.runPromise,
|
|
50
|
-
);
|
|
51
|
-
```
|
|
52
|
-
|
|
53
|
-
### TCP
|
|
54
|
-
|
|
55
|
-
```ts
|
|
56
|
-
import { Effect } from 'effect';
|
|
57
27
|
import { TcpTransportService } from '@flux-control/effect-modbus-rs';
|
|
58
28
|
|
|
59
29
|
const program = Effect.gen(function* () {
|
|
60
30
|
const transport = yield* TcpTransportService;
|
|
61
31
|
const client = yield* transport.withClient(1);
|
|
62
|
-
const
|
|
63
|
-
|
|
32
|
+
const registers = yield* client.readHoldingRegisters({ address: 0, quantity: 2 });
|
|
33
|
+
yield* Console.log(registers);
|
|
64
34
|
});
|
|
65
35
|
|
|
66
36
|
program.pipe(
|
|
67
|
-
Effect.provide(TcpTransportService.make({ host: '192.
|
|
37
|
+
Effect.provide(TcpTransportService.make({ host: '192.0.2.1', port: 502 })),
|
|
68
38
|
Effect.scoped,
|
|
69
39
|
Effect.runPromise,
|
|
70
40
|
);
|
|
71
41
|
```
|
|
72
42
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
import { Effect } from 'effect';
|
|
77
|
-
import { AsciiTransportService } from '@flux-control/effect-modbus-rs';
|
|
43
|
+
The transport opens on the first client request, not when the service tag is read.
|
|
44
|
+
It caches raw clients by unit ID and closes the connection when its scope ends.
|
|
45
|
+
All clients from one transport share the same connection.
|
|
78
46
|
|
|
79
|
-
|
|
80
|
-
const transport = yield* AsciiTransportService;
|
|
81
|
-
const client = yield* transport.withClient(1);
|
|
82
|
-
const registers = yield* client.readInputRegisters({
|
|
83
|
-
address: 0,
|
|
84
|
-
quantity: 5,
|
|
85
|
-
});
|
|
86
|
-
console.log('Input registers:', registers);
|
|
87
|
-
});
|
|
47
|
+
## Transports
|
|
88
48
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
49
|
+
| Service | Connection options | Binding |
|
|
50
|
+
| --------------------------- | --------------------------------------- | --------------------- |
|
|
51
|
+
| `TcpTransportService` | `host`, `port` | `AsyncTcpTransport` |
|
|
52
|
+
| `RtuTransportService` | `portPath`, `baudRate`, serial settings | `AsyncRtuTransport` |
|
|
53
|
+
| `AsciiTransportService` | `portPath`, `baudRate`, serial settings | `AsyncAsciiTransport` |
|
|
54
|
+
| `WasmWsTransportService` | `wsUrl`, optional request timeout | `WasmWsTransport` |
|
|
55
|
+
| `WasmRtuTransportService` | `port`, `baudRate`, serial settings | `WasmRtuTransport` |
|
|
56
|
+
| `WasmAsciiTransportService` | `port`, `baudRate`, serial settings | `WasmAsciiTransport` |
|
|
95
57
|
|
|
96
|
-
|
|
58
|
+
Concrete services expose `make(options)` layer factories and `makeScoped(options)` constructor effects.
|
|
59
|
+
Bindings load dynamically when a live connection is requested.
|
|
60
|
+
Browser initialization does not need to load native transports.
|
|
97
61
|
|
|
98
|
-
|
|
62
|
+
### Serial transports
|
|
99
63
|
|
|
100
|
-
|
|
101
|
-
|
|
64
|
+
For native serial connections, provide `RtuTransportService.make` or `AsciiTransportService.make` with a port path and baud rate.
|
|
65
|
+
`SerialTransportService` provides one service tag for either framing format.
|
|
66
|
+
Its `fromRtu(options)` and `fromAscii(options)` methods provide that tag.
|
|
102
67
|
|
|
103
68
|
```ts
|
|
104
69
|
import { Console, Effect } from 'effect';
|
|
105
|
-
import {
|
|
70
|
+
import { SerialTransportService } from '@flux-control/effect-modbus-rs';
|
|
106
71
|
|
|
107
72
|
const program = Effect.gen(function* () {
|
|
108
|
-
const transport = yield*
|
|
73
|
+
const transport = yield* SerialTransportService;
|
|
109
74
|
const client = yield* transport.withClient(1);
|
|
110
|
-
|
|
111
|
-
console.log('Holding registers:', registers);
|
|
75
|
+
yield* Console.log(yield* client.readCoils({ address: 0, quantity: 2 }));
|
|
112
76
|
});
|
|
113
77
|
|
|
114
78
|
program.pipe(
|
|
115
|
-
Effect.provide(
|
|
79
|
+
Effect.provide(SerialTransportService.fromRtu({ portPath: '/dev/ttyUSB0', baudRate: 9600 })),
|
|
116
80
|
Effect.scoped,
|
|
117
81
|
Effect.runPromise,
|
|
118
82
|
);
|
|
119
83
|
```
|
|
120
84
|
|
|
121
|
-
|
|
85
|
+
### Browser transports
|
|
86
|
+
|
|
87
|
+
`WasmWsTransportService.make({ wsUrl })` connects to a WebSocket-to-TCP gateway.
|
|
88
|
+
A browser cannot open a raw Modbus TCP socket.
|
|
89
|
+
The gateway must connect to the target Modbus device.
|
|
90
|
+
|
|
91
|
+
Web Serial client transports require a `WasmSerialPortHandle` from `requestSerialPort()`.
|
|
92
|
+
Run the request effect from a user-gesture handler, such as a button click.
|
|
93
|
+
The helper imports the browser binding asynchronously before it requests the port.
|
|
94
|
+
It does not call the browser permission API synchronously in the handler.
|
|
95
|
+
Permission behavior depends on browser support and transient user activation.
|
|
122
96
|
|
|
123
97
|
```ts
|
|
124
|
-
import {
|
|
98
|
+
import { Console, Effect } from 'effect';
|
|
125
99
|
import { requestSerialPort, WasmRtuTransportService } from '@flux-control/effect-modbus-rs';
|
|
126
100
|
|
|
127
|
-
connectButton.
|
|
128
|
-
|
|
101
|
+
const connectButton = document.querySelector<HTMLButtonElement>('#connect');
|
|
102
|
+
|
|
103
|
+
connectButton?.addEventListener('click', () => {
|
|
104
|
+
void Effect.runPromise(
|
|
129
105
|
Effect.gen(function* () {
|
|
130
106
|
const port = yield* requestSerialPort();
|
|
131
|
-
yield*
|
|
107
|
+
yield* Effect.gen(function* () {
|
|
108
|
+
const transport = yield* WasmRtuTransportService;
|
|
109
|
+
const client = yield* transport.withClient(1);
|
|
110
|
+
yield* Console.log(yield* client.readHoldingRegisters({ address: 0, quantity: 2 }));
|
|
111
|
+
}).pipe(
|
|
132
112
|
Effect.provide(WasmRtuTransportService.make({ port, baudRate: 19200 })),
|
|
133
113
|
Effect.scoped,
|
|
134
114
|
);
|
|
@@ -137,808 +117,495 @@ connectButton.addEventListener('click', () => {
|
|
|
137
117
|
});
|
|
138
118
|
```
|
|
139
119
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
120
|
+
`WasmSerialTransportService.fromRtu` and `.fromAscii` provide the abstract browser serial tag.
|
|
121
|
+
Both accept a port handle obtained through the same helper.
|
|
122
|
+
See [the browser example](examples/wasm/README.md) for setup and browser requirements.
|
|
123
|
+
|
|
124
|
+
## Raw client API
|
|
125
|
+
|
|
126
|
+
`transport.withClient(unitId, options?)` returns an `EffectModbusClient`.
|
|
127
|
+
Each operation returns an `Effect` with typed `ModbusError` failures.
|
|
128
|
+
Addresses are zero-based protocol addresses.
|
|
129
|
+
Support for a function code depends on the device and binding.
|
|
130
|
+
|
|
131
|
+
| Method | Function code | Success value |
|
|
132
|
+
| -------------------------------------------------------------------------------------- | ------------- | ------------------------------ |
|
|
133
|
+
| `readHoldingRegisters({ address, quantity })` | FC03 | `Uint16Array` |
|
|
134
|
+
| `readInputRegisters({ address, quantity })` | FC04 | `Uint16Array` |
|
|
135
|
+
| `writeSingleRegister({ address, value })` | FC06 | `void` |
|
|
136
|
+
| `writeMultipleRegisters({ address, values })` | FC16 | `void` |
|
|
137
|
+
| `readWriteMultipleRegisters({ readAddress, readQuantity, writeAddress, writeValues })` | FC23 | `Uint16Array` |
|
|
138
|
+
| `readCoils({ address, quantity })` | FC01 | `CoilState[]` |
|
|
139
|
+
| `readDiscreteInputs({ address, quantity })` | FC02 | `CoilState[]` |
|
|
140
|
+
| `writeSingleCoil({ address, value })` | FC05 | `void` |
|
|
141
|
+
| `writeMultipleCoils({ address, values })` | FC15 | `void` |
|
|
142
|
+
| `readExceptionStatus()` | FC07 | `number` |
|
|
143
|
+
| `diagnostics({ subFunction, data })` | FC08 | `DiagnosticsResponse` |
|
|
144
|
+
| `readFileRecord({ requests })` | FC20 | `Uint16Array[]` |
|
|
145
|
+
| `writeFileRecord({ requests })` | FC21 | `void` |
|
|
146
|
+
| `readFifoQueue({ address })` | FC24 | `FifoQueueResponse` |
|
|
147
|
+
| `readDeviceIdentification({ readDeviceIdCode, objectId })` | FC43 / MEI 14 | `DeviceIdentificationResponse` |
|
|
148
|
+
|
|
149
|
+
Coil operations use `CoilState.On` and `CoilState.Off` from `modbus-rs`, rather than boolean values.
|
|
150
|
+
FC23 performs its write and read within one Modbus request.
|
|
151
|
+
It does not make separate application operations atomic.
|
|
145
152
|
|
|
146
|
-
|
|
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
|
-
|
|
151
|
-
## Transports
|
|
152
|
-
|
|
153
|
-
Each transport is a scoped `Context.Service`. You provide it with `Effect.provide`, and the connection is opened on service access and closed when the scope ends.
|
|
154
|
-
|
|
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()` |
|
|
163
|
-
|
|
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).
|
|
165
|
-
|
|
166
|
-
### Abstract serial transport
|
|
153
|
+
## Transaction batching
|
|
167
154
|
|
|
168
|
-
`
|
|
155
|
+
Use `withClient` when the caller selects exact Modbus transactions.
|
|
156
|
+
Use `withBatchingClient` to collect register operations, plan contiguous transactions, and suppress redundant writes.
|
|
157
|
+
Batching does not cover coils, diagnostics, or file records.
|
|
169
158
|
|
|
170
159
|
```ts
|
|
171
160
|
import { Console, Effect } from 'effect';
|
|
172
|
-
import {
|
|
161
|
+
import { TcpTransportService } from '@flux-control/effect-modbus-rs';
|
|
173
162
|
|
|
174
163
|
const program = Effect.gen(function* () {
|
|
175
|
-
const transport = yield*
|
|
176
|
-
const client = yield* transport.
|
|
177
|
-
|
|
178
|
-
|
|
164
|
+
const transport = yield* TcpTransportService;
|
|
165
|
+
const client = yield* transport.withBatchingClient(3, {
|
|
166
|
+
debounce: {
|
|
167
|
+
writes: { window: '250 millis', maxHold: '1 second' },
|
|
168
|
+
reads: { window: '5 millis' },
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
yield* client.writeAllNow([
|
|
173
|
+
{ address: 2000, value: 512 },
|
|
174
|
+
{ address: 2001, value: 256 },
|
|
175
|
+
]);
|
|
176
|
+
yield* Console.log(yield* client.readAllNow([0, 1, 2, 32, 33]));
|
|
177
|
+
yield* client.onShutdown(client.writeAllNow([{ address: 2000, value: 0 }]));
|
|
179
178
|
});
|
|
180
179
|
|
|
181
|
-
// RTU framing
|
|
182
180
|
program.pipe(
|
|
183
|
-
Effect.provide(
|
|
181
|
+
Effect.provide(TcpTransportService.make({ host: '192.0.2.1', port: 502 })),
|
|
184
182
|
Effect.scoped,
|
|
185
183
|
Effect.runPromise,
|
|
186
184
|
);
|
|
187
|
-
|
|
188
|
-
// Or ASCII framing
|
|
189
|
-
// program.pipe(
|
|
190
|
-
// Effect.provide(SerialTransportService.fromAscii({ portPath: "/dev/ttyUSB0", baudRate: 9600 })),
|
|
191
|
-
// Effect.scoped,
|
|
192
|
-
// Effect.runPromise,
|
|
193
|
-
// );
|
|
194
|
-
```
|
|
195
|
-
|
|
196
|
-
`WasmSerialTransportService` is the browser equivalent. Provide it with `fromRtu` or `fromAscii`, and give it a port handle from `requestSerialPort`.
|
|
197
|
-
|
|
198
|
-
Both abstract tags also have `makeMockTransport` for tests. The option set is the same as the option set of the concrete tags. Thus a test that keeps the framing abstract can also set `retry`, `reconnect`, and the mock fault hooks:
|
|
199
|
-
|
|
200
|
-
```ts
|
|
201
|
-
import {
|
|
202
|
-
ModbusTimeoutError,
|
|
203
|
-
RetryPolicies,
|
|
204
|
-
SerialTransportService,
|
|
205
|
-
} from '@flux-control/effect-modbus-rs';
|
|
206
|
-
|
|
207
|
-
let attempts = 0;
|
|
208
|
-
|
|
209
|
-
const layer = SerialTransportService.makeMockTransport([device])({
|
|
210
|
-
portPath: '/dev/ttyUSB0',
|
|
211
|
-
baudRate: 9600,
|
|
212
|
-
retry: RetryPolicies.serial(),
|
|
213
|
-
// The first two attempts of each operation fail. The policy retries them.
|
|
214
|
-
fault: () =>
|
|
215
|
-
attempts++ < 2
|
|
216
|
-
? new ModbusTimeoutError({ message: 'no response', cause: new Error('timeout') })
|
|
217
|
-
: undefined,
|
|
218
|
-
});
|
|
219
185
|
```
|
|
220
186
|
|
|
221
|
-
|
|
187
|
+
### Batching client methods
|
|
222
188
|
|
|
223
|
-
|
|
189
|
+
| Method | Behavior |
|
|
190
|
+
| ----------------------------------------- | ---------------------------------------------------------------- |
|
|
191
|
+
| `write(write, attributes?)` | Collect one holding-register write for the configured window. |
|
|
192
|
+
| `writeNow(write, attributes?)` | Enqueue one write and flush pending writes immediately. |
|
|
193
|
+
| `writeAll(writes, attributes?)` | Collect a group as one caller for the configured window. |
|
|
194
|
+
| `writeAllNow(writes, attributes?)` | Enqueue a group and flush pending writes immediately. |
|
|
195
|
+
| `read(address)` | Collect one holding-register read. |
|
|
196
|
+
| `readNow(address)` | Enqueue one address and flush collected reads immediately. |
|
|
197
|
+
| `readAll(addresses)` | Collect a group and return values in the requested order. |
|
|
198
|
+
| `readAllNow(addresses)` | Enqueue a group and flush collected reads immediately. |
|
|
199
|
+
| `inputs.read` through `inputs.readAllNow` | Apply the same read methods to input registers. |
|
|
200
|
+
| `flush` | Flush writes, holding-register reads, then input-register reads. |
|
|
201
|
+
| `onShutdown(action)` | Register an action in the calling scope. |
|
|
224
202
|
|
|
225
|
-
`
|
|
203
|
+
`flush` reports batch outcomes to waiting callers, not through its own typed error channel.
|
|
204
|
+
`client.cache` exposes the write cache, or `undefined` when caching is disabled.
|
|
205
|
+
`client.debounce` exposes frozen, resolved debounce durations when configured.
|
|
226
206
|
|
|
227
|
-
###
|
|
207
|
+
### Collection windows and completion
|
|
228
208
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
| `writeSingleRegister({ address, value })` | `void` |
|
|
234
|
-
| `writeMultipleRegisters({ address, values })` | `void` |
|
|
235
|
-
| `readWriteMultipleRegisters({ readAddress, readQuantity, writeAddress, writeValues })` | `number[]` |
|
|
209
|
+
Debounce windows default to zero.
|
|
210
|
+
A zero window issues operations without a collection timer, including grouped operations.
|
|
211
|
+
With a positive window, `writeAll` and `readAll` also collect before issuing their groups.
|
|
212
|
+
Use the `Now` methods to flush immediately.
|
|
236
213
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
| `readCoils({ address, quantity })` | `boolean[]` |
|
|
242
|
-
| `writeSingleCoil({ address, value })` | `void` |
|
|
243
|
-
| `writeMultipleCoils({ address, values })` | `void` |
|
|
244
|
-
| `readDiscreteInputs({ address, quantity })` | `boolean[]` |
|
|
245
|
-
|
|
246
|
-
### Diagnostics & file access
|
|
247
|
-
|
|
248
|
-
| Method | Returns |
|
|
249
|
-
| ---------------------------------------------------------- | ------------------------------ |
|
|
250
|
-
| `readExceptionStatus()` | `number` |
|
|
251
|
-
| `diagnostics({ subFunction, data })` | `DiagnosticsResponse` |
|
|
252
|
-
| `readFifoQueue({ address })` | `FifoQueueResponse` |
|
|
253
|
-
| `readFileRecord({ requests })` | `number[][]` |
|
|
254
|
-
| `writeFileRecord({ requests })` | `void` |
|
|
255
|
-
| `readDeviceIdentification({ readDeviceIdCode, objectId })` | `DeviceIdentificationResponse` |
|
|
256
|
-
|
|
257
|
-
## Transaction batching
|
|
258
|
-
|
|
259
|
-
`withClient` issues exactly the transaction you name, and stays the right client when you know what the bus should carry. `withBatchingClient` is its sibling for the other case — code with one accessor per register, which knows what it wants to read and write but not what that ought to cost.
|
|
260
|
-
|
|
261
|
-
```ts
|
|
262
|
-
Effect.gen(function* () {
|
|
263
|
-
const client = yield* transport.withClient(3); // exact read
|
|
264
|
-
yield* client.readHoldingRegisters({ address: 2000, quantity: 2 });
|
|
265
|
-
|
|
266
|
-
const batched = yield* transport.withBatchingClient(3); // decides the transactions
|
|
267
|
-
yield* batched.writeAll([
|
|
268
|
-
{ address: 2000, value: 512 },
|
|
269
|
-
{ address: 2001, value: 256 },
|
|
270
|
-
]); // one FC16
|
|
271
|
-
yield* batched.readAll([0x0000, 0x0001, 0x0002, 0x0020, 0x0021]); // two FC03
|
|
272
|
-
});
|
|
273
|
-
```
|
|
274
|
-
|
|
275
|
-
Three things bring the transaction count down, and each is exported on its own:
|
|
276
|
-
|
|
277
|
-
| Piece | What it does |
|
|
278
|
-
| ---------------------------------------------- | ----------------------------------------------------------------------------------- |
|
|
279
|
-
| `planWrites` / `planReads` | Pack neighbouring addresses into the fewest transactions. Pure, no state, no I/O. |
|
|
280
|
-
| `createWriteDebouncer` / `createReadDebouncer` | Collect operations that arrive near each other, so a planner has something to pack. |
|
|
281
|
-
| `createRegisterCache` | Drop a write whose value the device already holds. |
|
|
282
|
-
|
|
283
|
-
### Batching client API
|
|
284
|
-
|
|
285
|
-
| Method | Function code | Notes |
|
|
286
|
-
| ----------------------------------- | ------------- | ---------------------------------------------------------- |
|
|
287
|
-
| `write({ address, value })` | FC06 / FC16 | Held for the write window, if one is configured. |
|
|
288
|
-
| `writeNow({ address, value })` | FC06 / FC16 | Enqueue with supersede, then flush and join. Newest wins. |
|
|
289
|
-
| `writeAll(writes)` | FC06 / FC16 | One caller, one group, planned together. |
|
|
290
|
-
| `writeAllNow(writes)` | FC06 / FC16 | The same, issued immediately. |
|
|
291
|
-
| `read(address)` | FC03 | Collected for the read window, if one is configured. |
|
|
292
|
-
| `readNow(address)` | FC03 | Immediate, and answers every reader already collected. |
|
|
293
|
-
| `readAll(addresses)` | FC03 | One caller, planned into spans, values in the order asked. |
|
|
294
|
-
| `readAllNow(addresses)` | FC03 | The same, issued immediately. |
|
|
295
|
-
| `inputs.read` … `inputs.readAllNow` | FC04 | The same four reads over the input registers. |
|
|
296
|
-
| `flush` | — | Issue everything pending, now. Never fails. |
|
|
297
|
-
| `cache` | — | The cache this client filters against, or `undefined`. |
|
|
298
|
-
|
|
299
|
-
**A `BatchingModbusClient` is not an `EffectModbusClient`.** It deliberately does not extend `ModbusOperations`: there is no `writeSingleRegister` and no `readHoldingRegisters` on it. For a given unit, choose one holding-register write path for the transport's lifetime. Once a batching client exists, raw FC06, FC16, and FC23 operations for that unit fail with `ModbusInvalidArgumentError`; otherwise they could bypass the pending batch and its cache. A raw client for the same unit remains available for exact reads, coils, file records, diagnostics, and the other non-register-write operations. Coils are not covered by batching because the planners pack registers.
|
|
300
|
-
|
|
301
|
-
Each unit can have a low-level client, a batching client, or both at once. Once a batching client exists for a unit, the low-level client can still read registers, read/write coils, and use diagnostics — but it can no longer write holding registers directly. A direct write is blocked with a typed error, so it cannot slip past the batch or leave the cache out of date. All holding-register writes for that unit must go through the batching client instead.
|
|
302
|
-
|
|
303
|
-
```mermaid
|
|
304
|
-
sequenceDiagram
|
|
305
|
-
participant App
|
|
306
|
-
participant Registry as Batching registry
|
|
307
|
-
participant Raw as Low-level client
|
|
308
|
-
participant Batch as Batching client
|
|
309
|
-
participant Dev as Device
|
|
310
|
-
|
|
311
|
-
App->>Registry: withBatchingClient(unitId)
|
|
312
|
-
Registry-->>App: BatchingModbusClient
|
|
313
|
-
Note over Registry: unit now has a batching client
|
|
314
|
-
|
|
315
|
-
App->>Raw: writeSingleRegister(unitId, ...)
|
|
316
|
-
Raw->>Registry: guarded write check
|
|
317
|
-
Registry-->>Raw: fails: ModbusInvalidArgumentError
|
|
318
|
-
Note over Raw: reads, coils, diagnostics,\nfile records still work on Raw
|
|
319
|
-
|
|
320
|
-
App->>Batch: write(...)
|
|
321
|
-
Batch->>Registry: enqueue in debouncer
|
|
322
|
-
Registry->>Dev: flush (via the same underlying client)
|
|
323
|
-
Dev-->>Registry: ack
|
|
324
|
-
Registry-->>Batch: resolve
|
|
325
|
-
```
|
|
326
|
-
|
|
327
|
-
### Windows
|
|
328
|
-
|
|
329
|
-
Nothing is debounced unless you ask for it, the same way nothing retries or reconnects unless you ask:
|
|
330
|
-
|
|
331
|
-
```ts
|
|
332
|
-
Effect.gen(function* () {
|
|
333
|
-
const batched = yield* transport.withBatchingClient(3, {
|
|
334
|
-
debounce: {
|
|
335
|
-
writes: { window: '250 millis', maxHold: '1 second' },
|
|
336
|
-
reads: { window: '5 millis' },
|
|
337
|
-
},
|
|
338
|
-
});
|
|
339
|
-
});
|
|
340
|
-
```
|
|
341
|
-
|
|
342
|
-
A write is held for `window`, and each new arrival restarts it. `maxHold` caps the total hold, so a register that updates faster than the window still reaches the wire — without the ceiling, every arrival would postpone the wait forever. It defaults to four times `window`.
|
|
343
|
-
|
|
344
|
-
A write does not go to the device right away. It waits in the debouncer for the length of `window`. Each new write to the same address resets that wait, so a burst of updates can still land as one transaction. `maxHold` sets a limit on the total wait, so a register that updates fast still reaches the device on time. When the window ends, or `maxHold` is reached, the debouncer sends the batch and tells every waiting caller the result.
|
|
214
|
+
Each new write restarts the write window.
|
|
215
|
+
`maxHold` limits the total delay from the first write in the batch.
|
|
216
|
+
It defaults to four times the write window.
|
|
217
|
+
The read window starts with the first reader and does not restart.
|
|
345
218
|
|
|
346
219
|
```mermaid
|
|
347
220
|
stateDiagram-v2
|
|
348
221
|
[*] --> Idle
|
|
349
|
-
Idle -->
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
Flushing --> Idle:
|
|
353
|
-
```
|
|
354
|
-
|
|
355
|
-
```mermaid
|
|
356
|
-
sequenceDiagram
|
|
357
|
-
participant A as Caller A
|
|
358
|
-
participant B as Caller B
|
|
359
|
-
participant D as Debouncer
|
|
360
|
-
participant Dev as Device
|
|
361
|
-
|
|
362
|
-
A->>D: write(2000, 10)
|
|
363
|
-
Note over D: batch opens, window timer starts
|
|
364
|
-
B->>D: write(2001, 20)
|
|
365
|
-
Note over D: new arrival restarts window\n(unless maxHold is closer)
|
|
366
|
-
Note over D: window elapses OR maxHold reached
|
|
367
|
-
D->>Dev: flush (one packed transaction)
|
|
368
|
-
Dev-->>D: ack / error
|
|
369
|
-
D-->>A: write() resolves
|
|
370
|
-
D-->>B: write() resolves
|
|
371
|
-
```
|
|
372
|
-
|
|
373
|
-
A read window does not restart: it opens on the first arrival and expires on time, which a stream of readers cannot push out. A useful size is on the order of one transaction; measure one on your bus before choosing.
|
|
374
|
-
|
|
375
|
-
`writeAll` and `readAll` plan whatever they are given, so code that already holds a group of registers needs no window at all.
|
|
376
|
-
|
|
377
|
-
### The cache
|
|
378
|
-
|
|
379
|
-
The cache records what this process wrote, keyed by unit and address. It is not a read cache and never answers a read. One cache serves every device on a transport, because a cache is a belief about a physical device and two caches on one unit disagree.
|
|
380
|
-
|
|
381
|
-
It is emptied for a unit after a failed write, and emptied entirely when the link is lost — a device that power-cycles comes back holding something else. An invalidation that lands while a write is still on the wire wins over the observation that follows it, so losing the link cannot be undone by a write that was already in flight. Pass `cache: false` to write every value, or pass your own `RegisterCache` to share one.
|
|
382
|
-
|
|
383
|
-
Suppressing a write is not always cheaper. Dropping one address out of the middle of a contiguous run splits that run into one FC06 per register, so a cache meant to save turnarounds can spend them instead. Each batch is therefore planned both ways — filtered, and whole — and the plan with fewer transactions wins, with a tie going to the filtered one because its frames are shorter. Keeping a run whole rewrites a register with the value the cache believes it already holds, and never sends more than `cache: false` would have. If you need a register left alone rather than rewritten, use `withClient`: it issues exactly the transaction you name.
|
|
384
|
-
|
|
385
|
-
The cache and the fiber that watches the link are created on first use, so a transport nobody batches on carries neither.
|
|
386
|
-
|
|
387
|
-
### Declaring a unit, and reaching for it
|
|
388
|
-
|
|
389
|
-
Every option a batching client takes is a fact about the unit, not about a caller. One unit holds one batch, one belief about what the device contains, and one window — so two callers can only ever configure a unit correctly by passing identical options. Rather than compare them, the two operations are separate:
|
|
390
|
-
|
|
391
|
-
```ts
|
|
392
|
-
const damper =
|
|
393
|
-
yield * transport.withBatchingClient(3, { debounce: { writes: { window: '50 millis' } } });
|
|
394
|
-
const same = yield * transport.batchingClient(3); // elsewhere, no options to restate
|
|
395
|
-
```
|
|
396
|
-
|
|
397
|
-
`withBatchingClient` declares. Declaring a unit twice fails with `ModbusUnitAlreadyDeclaredError`, whatever the second call asks for. `batchingClient` looks up, and fails with `ModbusInvalidArgumentError` when nothing has declared that unit. A lookup that arrives while a declaration is still in flight waits for it, so neither call has to know which one ran first.
|
|
398
|
-
|
|
399
|
-
A declaration belongs to the transport scope, not to the caller. It stays when the caller is interrupted, and when the owner that made it closes while the transport stays open. An owner that can start again over the same transport can catch the error and recover the existing client:
|
|
400
|
-
|
|
401
|
-
```ts
|
|
402
|
-
const damper =
|
|
403
|
-
yield *
|
|
404
|
-
transport
|
|
405
|
-
.withBatchingClient(3, { debounce })
|
|
406
|
-
.pipe(
|
|
407
|
-
Effect.catchTag('ModbusUnitAlreadyDeclaredError', (error) =>
|
|
408
|
-
transport.batchingClient(error.unitId),
|
|
409
|
-
),
|
|
410
|
-
);
|
|
411
|
-
```
|
|
412
|
-
|
|
413
|
-
The recovered client keeps the options of the first declaration. `client.debounce` gives its debounce windows as frozen, resolved `Duration` values, so the owner can compare them with the windows that it expects. An omitted `maxHold` shows as four times the write window, which is the limit that the client applies.
|
|
414
|
-
|
|
415
|
-
### Shutdown
|
|
416
|
-
|
|
417
|
-
```ts
|
|
418
|
-
Effect.gen(function* () {
|
|
419
|
-
const damper = yield* transport.withBatchingClient(3);
|
|
420
|
-
yield* damper.onShutdown(damper.writeAllNow([{ address: 2000, value: 0 }]));
|
|
421
|
-
|
|
422
|
-
const motor = yield* transport.withBatchingClient(7);
|
|
423
|
-
yield* motor.onShutdown(motor.writeNow({ address: 40, value: MOTOR_STOP }));
|
|
424
|
-
});
|
|
425
|
-
```
|
|
426
|
-
|
|
427
|
-
A safe state is the device's own answer — zero volts for one, a stopped motor for
|
|
428
|
-
another — so it is stated on the client that addresses the device. A unit whose
|
|
429
|
-
client registers nothing is left alone. The action runs when the calling scope
|
|
430
|
-
closes, while the transport is still open, and before the client is torn down.
|
|
431
|
-
|
|
432
|
-
A failure is logged and raised as a defect. The other actions in the scope still
|
|
433
|
-
run, so a device that cannot be reached does not cost the rest of the bus its
|
|
434
|
-
turn. Wrap the action in `Effect.ignoreLogged` to accept the failure instead.
|
|
435
|
-
|
|
436
|
-
`transport.touchedUnits` reports which units a client was built for. That is
|
|
437
|
-
transport-wide diagnostic state, not device ownership, and it is not the input to
|
|
438
|
-
a shutdown policy.
|
|
439
|
-
|
|
440
|
-
### Spans
|
|
441
|
-
|
|
442
|
-
A batching client opens `modbus.write` and `modbus.read` spans, carrying:
|
|
443
|
-
|
|
444
|
-
| Attribute | Content |
|
|
445
|
-
| -------------------------- | --------------------------- |
|
|
446
|
-
| `modbus.unit_ids` | The units the batch reached |
|
|
447
|
-
| `modbus.register_count` | Registers written or read |
|
|
448
|
-
| `modbus.suppressed_count` | Writes the cache removed |
|
|
449
|
-
| `modbus.transaction_count` | Transactions issued |
|
|
450
|
-
|
|
451
|
-
The write span opens _after_ the cache filter, and only when a write survives it, so it records what reached the bus rather than what was proposed. Only the callers whose values are in the transaction contribute their vocabulary to it — a suppressed caller does not name itself on a frame that did not carry its value. Attach your own vocabulary as a second argument:
|
|
452
|
-
|
|
453
|
-
```ts
|
|
454
|
-
Effect.gen(function* () {
|
|
455
|
-
yield* batched.write({ address: 2000, value: 512 }, { 'app.point': 'Supply fan' });
|
|
456
|
-
});
|
|
457
|
-
```
|
|
458
|
-
|
|
459
|
-
Your keys are opaque to this package and are applied first; the `modbus.*` keys are applied last and win a collision, so a caller cannot overwrite the record of what the library wrote. When a batch carries several callers' worth of vocabulary, values for a repeated key are joined with a comma rather than one silently winning.
|
|
460
|
-
|
|
461
|
-
### Using the layers directly
|
|
462
|
-
|
|
463
|
-
The batching client is a composition, not a wall. Each layer is exported, and each works without the layer above it.
|
|
464
|
-
|
|
465
|
-
#### The planners
|
|
466
|
-
|
|
467
|
-
Pure — no Effect, no state, no I/O. Use them when you own your own scheduling and only want the packing.
|
|
468
|
-
|
|
469
|
-
```ts
|
|
470
|
-
import { planReads, planWrites } from '@flux-control/effect-modbus-rs';
|
|
471
|
-
|
|
472
|
-
planWrites([
|
|
473
|
-
{ address: 2003, value: 40 },
|
|
474
|
-
{ address: 2000, value: 10 },
|
|
475
|
-
{ address: 2010, value: 99 },
|
|
476
|
-
{ address: 2001, value: 20 },
|
|
477
|
-
{ address: 2002, value: 30 },
|
|
478
|
-
]);
|
|
479
|
-
// [
|
|
480
|
-
// { kind: "multiple", address: 2000, values: Uint16Array [10, 20, 30, 40] },
|
|
481
|
-
// { kind: "single", address: 2010, value: 99 },
|
|
482
|
-
// ]
|
|
483
|
-
|
|
484
|
-
const plan = planReads([0x0000, 0x0001, 0x0002, 0x0020, 0x0021]);
|
|
485
|
-
plan.spans; // [{ address: 0, quantity: 3 }, { address: 32, quantity: 2 }]
|
|
486
|
-
plan.locate(0x0021); // { span: 1, offset: 1 } — index back into the responses
|
|
487
|
-
plan.locate(0x0010); // undefined
|
|
488
|
-
```
|
|
489
|
-
|
|
490
|
-
Each step maps onto exactly one client call: `single` onto `writeSingleRegister`, `multiple` onto `writeMultipleRegisters`. `locate` answers for every address a span covers, so you issue the spans, keep the responses in order, and read each value out without tracking the grouping yourself.
|
|
491
|
-
|
|
492
|
-
`planWrites(writes, options?)`:
|
|
493
|
-
|
|
494
|
-
| Option | Default | What it does |
|
|
495
|
-
| ---------------------- | ---------------------------------- | ------------------------------------------------------------------------------- |
|
|
496
|
-
| `maxRegistersPerWrite` | `MODBUS_MAX_WRITE_REGISTERS` (123) | Registers one FC16 may carry. A longer run splits into consecutive steps. |
|
|
497
|
-
| `minRunLength` | `2` | Shortest run that becomes FC16. Anything shorter becomes one FC06 per register. |
|
|
498
|
-
|
|
499
|
-
`planReads(addresses, options?)`:
|
|
500
|
-
|
|
501
|
-
| Option | Default | What it does |
|
|
502
|
-
| --------------------- | --------------------------------- | ------------------------------------------------------------- |
|
|
503
|
-
| `maxRegistersPerRead` | `MODBUS_MAX_READ_REGISTERS` (125) | Registers one read may return. A span never grows past this. |
|
|
504
|
-
| `maxGap` | `0` | Unrequested registers the planner may read to join two spans. |
|
|
505
|
-
|
|
506
|
-
Both constants are the specification's limits, and both are exported. Many devices stop short of them — pass the device's own number when it does.
|
|
507
|
-
|
|
508
|
-
> **Raising `maxGap` can take healthy registers down with it.** A gap may cover an address the device does not implement. That span then fails with `ILLEGAL_DATA_ADDRESS`, and every address in it fails, including the ones that would have answered. Raise it only against a register map that says the gap is readable.
|
|
509
|
-
|
|
510
|
-
Both planners throw `RangeError` for an address, a value, or an option that is out of range, since those are programming errors rather than bus conditions. `encodeRegisterValue` is exported for the same reason the planners use it: `-1` and `65535` are the same register contents, so anything comparing a proposed value against a device value has to encode first or it will rewrite the register forever.
|
|
511
|
-
|
|
512
|
-
#### The debouncers and the cache
|
|
513
|
-
|
|
514
|
-
Stateful, scoped, and driven by callbacks you supply — use these to batch over a client this package did not hand out.
|
|
515
|
-
|
|
516
|
-
```ts
|
|
517
|
-
Effect.gen(function* () {
|
|
518
|
-
const writes = yield* createWriteDebouncer({
|
|
519
|
-
window: '250 millis',
|
|
520
|
-
maxHold: '1 second',
|
|
521
|
-
flush: (batch) => issueHowever(batch), // yours: cache, plan, span, write
|
|
522
|
-
});
|
|
523
|
-
|
|
524
|
-
// Two callers that never meet, one transaction:
|
|
525
|
-
yield* Effect.all(
|
|
526
|
-
[writes.write({ address: 2000, value: 10 }), writes.write({ address: 2001, value: 20 })],
|
|
527
|
-
{
|
|
528
|
-
concurrency: 'unbounded',
|
|
529
|
-
},
|
|
530
|
-
);
|
|
531
|
-
});
|
|
532
|
-
```
|
|
533
|
-
|
|
534
|
-
`createWriteDebouncer` takes `window`, an optional `maxHold` (four times `window` by default), and `flush`. It returns `write` / `writeNow` / `writeAll` / `writeAllNow`, a `flush` you can force, and a `pending` count for tests.
|
|
535
|
-
|
|
536
|
-
`createReadDebouncer` takes `window`, an optional `plan` (the `planReads` options), and `fetch`, which must return one response per span. Its callback owns the function code, so reading input registers rather than holding registers means a second debouncer. It returns `read` / `readNow` / `readAll` / `readAllNow`, plus `flush` and `pending`.
|
|
537
|
-
|
|
538
|
-
Both take a `Scope` and flush in it rather than in the caller's, so a caller interrupted mid-wait cannot take the pending batch down with it. When that scope closes, callers still waiting are interrupted — their operations never reached the device, and reporting success would break the invariant the `Deferred` exists to hold.
|
|
222
|
+
Idle --> Collecting: first write
|
|
223
|
+
Collecting --> Collecting: new write restarts window
|
|
224
|
+
Collecting --> Flushing: window expires, maxHold reached, or immediate flush
|
|
225
|
+
Flushing --> Idle: notify waiting callers
|
|
226
|
+
```
|
|
539
227
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
228
|
+
For repeated writes to the same address, the latest pending value replaces earlier values.
|
|
229
|
+
Earlier callers receive the result of the batch that contains the latest value.
|
|
230
|
+
Success does not prove that each caller's original value reached the device.
|
|
231
|
+
Cache filtering can also suppress a physical write.
|
|
232
|
+
|
|
233
|
+
A group can require several Modbus transactions.
|
|
234
|
+
If a later transaction fails, earlier writes can remain applied.
|
|
235
|
+
The package does not roll them back.
|
|
236
|
+
Batch completion does not prove that the device completed a physical action.
|
|
237
|
+
|
|
238
|
+
Closing the debouncer scope discards pending requests and interrupts their waiting callers.
|
|
239
|
+
Already-issued operations are not rolled back.
|
|
240
|
+
Interrupting one caller does not remove its request from the shared pending batch.
|
|
241
|
+
|
|
242
|
+
### Raw register-write guards
|
|
243
|
+
|
|
244
|
+
A `BatchingModbusClient` does not extend `ModbusOperations`.
|
|
245
|
+
After a unit has a batching declaration, raw FC06, FC16, and FC23 operations for that unit fail with `ModbusInvalidArgumentError`.
|
|
246
|
+
This guard prevents raw writes from bypassing pending writes and their cache.
|
|
247
|
+
Raw reads, coils, diagnostics, and file-record operations remain available through `withClient`.
|
|
248
|
+
|
|
249
|
+
### Declaring and retrieving clients
|
|
250
|
+
|
|
251
|
+
`withBatchingClient(unitId, options?)` declares one batching client per unit in the transport scope.
|
|
252
|
+
A second declaration fails with `ModbusUnitAlreadyDeclaredError`, including when the first declaration is still pending.
|
|
253
|
+
`batchingClient(unitId)` retrieves a declared client.
|
|
254
|
+
It waits for a pending declaration or fails with `ModbusInvalidArgumentError` if no declaration exists.
|
|
255
|
+
|
|
256
|
+
The declaration remains until the transport scope closes.
|
|
257
|
+
Interrupting its caller or closing a separate caller scope does not remove it.
|
|
258
|
+
To recover an existing declaration, catch `ModbusUnitAlreadyDeclaredError` and call `batchingClient(error.unitId)`.
|
|
259
|
+
The recovered client retains the first declaration's options.
|
|
260
|
+
Compare `client.debounce` with the durations the caller expects.
|
|
261
|
+
|
|
262
|
+
### Write cache
|
|
263
|
+
|
|
264
|
+
The cache records acknowledged writes by unit ID and address.
|
|
265
|
+
It does not read the device and does not answer reads.
|
|
266
|
+
It cannot detect changes made by another process or device logic.
|
|
267
|
+
One default cache serves the batching clients on a transport.
|
|
268
|
+
The cache and connection watcher are created when batching is first requested.
|
|
269
|
+
|
|
270
|
+
Cache comparisons use encoded unsigned 16-bit values.
|
|
271
|
+
For example, `-1` and `65535` have the same wire encoding.
|
|
272
|
+
After an issued write fails, the unit's cached values are invalidated.
|
|
273
|
+
When the connection state leaves `Connected`, all cached writes are invalidated.
|
|
274
|
+
An invalidation during an in-flight write prevents a later acknowledgement from restoring stale cache entries.
|
|
275
|
+
|
|
276
|
+
The client compares filtered and unfiltered write plans.
|
|
277
|
+
If the unfiltered plan uses fewer transactions, it can resend unchanged values to preserve a contiguous run.
|
|
278
|
+
If the counts are equal, it selects the filtered plan.
|
|
279
|
+
Pass `cache: false` to disable suppression.
|
|
280
|
+
Pass a `RegisterCache` instance to supply a custom cache.
|
|
281
|
+
|
|
282
|
+
### Shutdown actions
|
|
283
|
+
|
|
284
|
+
`client.onShutdown(action)` registers a finalizer in the calling scope.
|
|
285
|
+
Use an immediate write method for an action that must run before transport cleanup.
|
|
286
|
+
Close the caller scope while the transport remains open.
|
|
287
|
+
Call `transport.close(callerScope)` to finalize the caller scope before closing the connection.
|
|
288
|
+
Closing a different scope first can prevent the action from reaching the device.
|
|
289
|
+
|
|
290
|
+
An unhandled action failure is logged and raised as a defect.
|
|
291
|
+
Other finalizers still run.
|
|
292
|
+
Wrap the action in `Effect.ignoreLogged` to accept its failure.
|
|
293
|
+
The application selects the shutdown value; the library does not guarantee a device's safe state.
|
|
294
|
+
`transport.touchedUnits` is diagnostic state, not a shutdown policy or an ownership record.
|
|
295
|
+
|
|
296
|
+
### Tracing
|
|
297
|
+
|
|
298
|
+
Batching reads and writes use `modbus.read` and `modbus.write` spans.
|
|
299
|
+
Write spans are created after cache filtering and only when the chosen plan contains writes.
|
|
300
|
+
The span describes the selected plan; failed execution can leave some transactions unissued.
|
|
301
|
+
|
|
302
|
+
| Attribute | Meaning |
|
|
303
|
+
| -------------------------- | --------------------------------------------------- |
|
|
304
|
+
| `modbus.unit_ids` | Unit ID addressed by the batch. |
|
|
305
|
+
| `modbus.register_count` | Registers in the selected write plan or read spans. |
|
|
306
|
+
| `modbus.suppressed_count` | Proposed writes excluded from the selected plan. |
|
|
307
|
+
| `modbus.transaction_count` | Transactions in the selected plan. |
|
|
308
|
+
|
|
309
|
+
Write methods accept caller span attributes as their second argument.
|
|
310
|
+
Only attributes from the entries in the chosen write plan contribute.
|
|
311
|
+
`mergeSpanAttributes` deduplicates repeated values by string form and joins them with commas in first-seen order.
|
|
312
|
+
Reserved `modbus.*` attributes apply last and replace conflicting caller values.
|
|
313
|
+
|
|
314
|
+
## Independent batching utilities
|
|
315
|
+
|
|
316
|
+
The planners, debouncers, and cache can be used independently.
|
|
317
|
+
|
|
318
|
+
### Planners
|
|
319
|
+
|
|
320
|
+
`planWrites(writes, options?)` sorts addresses and keeps the latest value for each duplicate address.
|
|
321
|
+
It groups consecutive addresses and splits groups at the configured limit.
|
|
322
|
+
A `single` step maps to FC06; a `multiple` step maps to FC16.
|
|
323
|
+
|
|
324
|
+
`planReads(addresses, options?)` sorts and deduplicates addresses into spans.
|
|
325
|
+
The returned `locate(address)` finds the span index and offset for an address covered by a span.
|
|
326
|
+
It returns `undefined` outside the spans.
|
|
327
|
+
|
|
328
|
+
| Planner option | Default | Purpose |
|
|
329
|
+
| ---------------------- | --------------------------------- | -------------------------------------------------------- |
|
|
330
|
+
| `maxRegistersPerWrite` | `MODBUS_MAX_WRITE_REGISTERS`, 123 | Limit each FC16 transaction. |
|
|
331
|
+
| `minRunLength` | 2 | Minimum contiguous run for FC16. |
|
|
332
|
+
| `maxRegistersPerRead` | `MODBUS_MAX_READ_REGISTERS`, 125 | Limit each read span. |
|
|
333
|
+
| `maxGap` | 0 | Allow unrequested addresses between requested addresses. |
|
|
334
|
+
|
|
335
|
+
Use lower transaction limits when required by the device.
|
|
336
|
+
If a gap includes an unreadable address, its entire read transaction can fail.
|
|
337
|
+
Increase `maxGap` only when those addresses are readable.
|
|
338
|
+
The planners throw `RangeError` for invalid addresses, values, or options.
|
|
339
|
+
`encodeRegisterValue(value)` validates and encodes signed or unsigned 16-bit register values.
|
|
340
|
+
|
|
341
|
+
### Debouncers and cache
|
|
342
|
+
|
|
343
|
+
`createWriteDebouncer({ window, maxHold?, flush })` collects writes and invokes the supplied batch callback.
|
|
344
|
+
`createReadDebouncer({ window, plan?, fetch })` collects addresses and invokes the supplied span callback.
|
|
345
|
+
`fetch` must return one `Uint16Array` per span, with the requested quantity of values.
|
|
346
|
+
Both constructors require a scope.
|
|
347
|
+
Both expose normal and immediate operations, `flush`, and a `pending` address count.
|
|
348
|
+
Standalone debouncers do not provide a transport, retry policy, or cache.
|
|
349
|
+
|
|
350
|
+
`createRegisterCache()` provides `filter`, `observe`, `invalidate`, and generation tracking.
|
|
351
|
+
Call `observe` only after a successful write acknowledgement.
|
|
352
|
+
Use its generation token to preserve invalidations during an in-flight write.
|
|
353
|
+
|
|
354
|
+
`makeBatchingClient({ unitId, client, cache, debounce?, plan? })` composes these utilities over an `EffectModbusClient`.
|
|
355
|
+
Its `cache` field is required; pass `undefined` to omit a cache.
|
|
356
|
+
A raw promise-based binding client is not accepted directly.
|
|
357
|
+
|
|
358
|
+
`createBatchingRegistry(deps)` provides per-unit declarations and connection-driven cache invalidation for custom transports.
|
|
359
|
+
Pass public raw clients through `registry.guardRawWrites(unitId, client)` to enforce the register-write guard.
|
|
549
360
|
|
|
550
361
|
## Error handling
|
|
551
362
|
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
|
556
|
-
|
|
|
557
|
-
| `
|
|
558
|
-
| `
|
|
559
|
-
| `
|
|
560
|
-
| `
|
|
561
|
-
| `
|
|
562
|
-
| `
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
`
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
`
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
363
|
+
Binding failures are converted to tagged errors through `toModbusError`.
|
|
364
|
+
Handle typed failures with `Effect.catchTag` or `Effect.catchTags`.
|
|
365
|
+
|
|
366
|
+
| Error | Meaning |
|
|
367
|
+
| ----------------------------- | ----------------------------------------------------------- |
|
|
368
|
+
| `ModbusExceptionError` | Device returned a protocol exception; includes its code. |
|
|
369
|
+
| `ModbusTimeoutError` | Request or connection wait timed out. |
|
|
370
|
+
| `ModbusTransportError` | Transport or malformed-response failure. |
|
|
371
|
+
| `ModbusInvalidArgumentError` | Invalid operation parameters or local API use. |
|
|
372
|
+
| `ModbusConnectionClosedError` | Connection closed unexpectedly. |
|
|
373
|
+
| `ModbusNotConnectedError` | Operation needs an open transport or scope. |
|
|
374
|
+
| `ModbusCircuitOpenError` | Reconnection guard refused an operation without sending it. |
|
|
375
|
+
| `ModbusInternalError` | Unclassified failure. |
|
|
376
|
+
|
|
377
|
+
`ModbusError` includes these eight variants.
|
|
378
|
+
`ModbusUnitAlreadyDeclaredError` is a separate local declaration error and is not retryable through Modbus policies.
|
|
379
|
+
|
|
380
|
+
## Retries and reconnection
|
|
381
|
+
|
|
382
|
+
Retries and supervised reconnection are opt-in transport policies.
|
|
383
|
+
Without a retry policy, each operation has one attempt.
|
|
384
|
+
Without `reconnect`, no reconnection supervisor or circuit guard starts.
|
|
385
|
+
|
|
386
|
+
Configure `retry` and `reconnect` on the transport layer.
|
|
387
|
+
`reconnect: {}` enables the default supervisor policy and circuit guard.
|
|
388
|
+
|
|
389
|
+
### Retry policies
|
|
390
|
+
|
|
391
|
+
| Factory | Default budget and delay |
|
|
392
|
+
| ---------------------------- | ----------------------------------------------------------------------------- |
|
|
393
|
+
| `RetryPolicies.none()` | One attempt, no retries. |
|
|
394
|
+
| `RetryPolicies.serial()` | Three retries, 50 ms base, factor 2, 1 s maximum. |
|
|
395
|
+
| `RetryPolicies.tcp()` | Four retries, 100 ms base, factor 2, 5 s maximum. |
|
|
396
|
+
| `RetryPolicies.persistent()` | Ten retries, 250 ms base, factor 2, 30 s maximum, five-minute elapsed budget. |
|
|
397
|
+
|
|
398
|
+
Factories accept overrides.
|
|
399
|
+
`createRetryPolicy(options)` creates a policy without a template.
|
|
400
|
+
Policies retry timeouts, transport failures, closed connections, and open circuits by default.
|
|
401
|
+
Protocol exceptions retry only for codes 5, 6, 10, and 11 by default.
|
|
402
|
+
Invalid arguments, not-connected errors, and internal errors do not retry by default.
|
|
403
|
+
Per-error settings can disable or enable retries and set separate backoff values.
|
|
404
|
+
The retry count is shared across error categories.
|
|
405
|
+
|
|
406
|
+
Backoff uses `min(maxDelay, baseDelay × factor ** retryIndex)` before jitter.
|
|
407
|
+
Jitter defaults to a multiplier from 0.8 to 1.2.
|
|
408
|
+
Set `jitter: false` for deterministic delays, or provide `{ min, max }` multipliers.
|
|
409
|
+
|
|
410
|
+
### Policy replacement and compound retries
|
|
411
|
+
|
|
412
|
+
`withClient(unitId, { retry })` replaces the transport retry policy for that client.
|
|
413
|
+
`client.withRetry(policy)` replaces the active policy for operations through the returned client.
|
|
414
|
+
These policies do not combine.
|
|
415
|
+
Resolution order is operation override, client override, transport policy, then no retries.
|
|
416
|
+
|
|
417
|
+
`retryModbus(policy)` wraps an effect, including retries already inside it.
|
|
418
|
+
Nested policies multiply operation attempt counts.
|
|
419
|
+
For a compound read/write retry, first obtain a client with `RetryPolicies.none()`.
|
|
420
|
+
Then wrap the compound effect with `retryModbus`.
|
|
421
|
+
Repeating a compound operation can repeat earlier successful writes; it does not provide atomicity or rollback.
|
|
422
|
+
See [the retry example](examples/retry-policies.ts).
|
|
423
|
+
|
|
424
|
+
### Connection state and circuit guard
|
|
425
|
+
|
|
426
|
+
`transport.connectionState` is a `SubscriptionRef` of connection states.
|
|
427
|
+
One supervisor serves each transport, including when several callers report connection failures.
|
|
428
|
+
Default trigger errors are `ModbusConnectionClosedError` and `ModbusTransportError`.
|
|
429
|
+
The default reconnect policy allows five retries with 250 ms base delay, factor 2, and 10 s maximum delay.
|
|
430
|
+
The circuit reset interval defaults to 30 seconds.
|
|
431
|
+
Use `reconnect.policy`, `resetAfter`, and `triggerOn` to change these settings.
|
|
432
|
+
|
|
433
|
+
| State | Meaning |
|
|
434
|
+
| -------------- | ------------------------------------------------------------------------------------- |
|
|
435
|
+
| `Disconnected` | No open connection. Client acquisition opens it unless the transport has been closed. |
|
|
436
|
+
| `Connected` | The connection is available. |
|
|
437
|
+
| `Reconnecting` | The supervisor is reconnecting. The enabled guard rejects operations. |
|
|
438
|
+
| `Down` | A connection failure was observed. The enabled supervisor waits before probing again. |
|
|
439
|
+
|
|
440
|
+
With supervision enabled, operations in `Reconnecting` or `Down` fail with `ModbusCircuitOpenError`.
|
|
441
|
+
Retry policies can wait for recovery within their budgets.
|
|
442
|
+
Without supervision, `Down` does not activate a circuit guard.
|
|
443
|
+
Call `transport.reconnect()` for manual recovery.
|
|
444
|
+
A later successful operation also restores `Connected`.
|
|
445
|
+
|
|
446
|
+
### Connection and request timeouts
|
|
447
|
+
|
|
448
|
+
`connectTimeout` limits each caller's wait for an open or reconnect.
|
|
449
|
+
It is unbounded by default.
|
|
450
|
+
When the wait expires, the caller fails with `ModbusTimeoutError`.
|
|
451
|
+
The native connection attempt cannot be cancelled and continues after that timeout.
|
|
452
|
+
Later callers wait for the same pending attempt, each with their own timeout.
|
|
453
|
+
A late successful connection remains usable.
|
|
454
|
+
|
|
455
|
+
`requestTimeoutMs` limits requests through binding options.
|
|
456
|
+
It does not limit connection acquisition.
|
|
457
|
+
`setRequestTimeout(ms)` and `clearRequestTimeout()` require an open transport.
|
|
458
|
+
`transport.close(scope)` finalizes the supplied caller scope, then closes the transport even if a finalizer fails.
|
|
459
|
+
Later use cannot reopen that closed transport.
|
|
460
|
+
|
|
461
|
+
### Native request limits
|
|
462
|
+
|
|
463
|
+
The native services use `modbus-rs` 0.16.3.
|
|
464
|
+
`responseTimeoutMs` limits a device response. `requestTimeoutMs` limits admission to the native queue.
|
|
465
|
+
If `responseTimeoutMs` is absent, the services use `requestTimeoutMs` as its fallback.
|
|
466
|
+
The native default response limit is 1000 ms.
|
|
467
|
+
The services do not replace the response option with an automatic `setRequestTimeout` call.
|
|
468
|
+
An isolated request timeout does not cause an automatic reconnect.
|
|
469
|
+
|
|
470
|
+
The native `setRequestTimeout` method changes both limits. `clearRequestTimeout` removes both limits.
|
|
471
|
+
Serial calls retain one drain lock, including their guard and failure report.
|
|
472
|
+
An Effect interruption waits for a bounded native call to finish.
|
|
473
|
+
If a serial call has no response limit, interruption closes its handle instead of waiting indefinitely.
|
|
474
|
+
Later operations open a new handle and preserve explicit runtime limit settings.
|
|
475
|
+
An interrupted caller that is still waiting for the lock sends no request.
|
|
476
|
+
|
|
477
|
+
The 0.16.3 binding drains a cancelled serial response before it sends the next request.
|
|
478
|
+
This fixes the follow-up checksum error reproduced with a raw native `AbortSignal` in 0.16.2.
|
|
479
|
+
Use Effect fiber interruption to cancel service operations.
|
|
480
|
+
TCP requests remain concurrent. The WASM service code does not change.
|
|
481
|
+
|
|
482
|
+
Serial paths can contain up to 128 characters in this binding version.
|
|
483
|
+
A longer stable link uses a short scoped alias to the configured path.
|
|
484
|
+
The alias follows link changes during device re-enumeration. Scope close removes the alias.
|
|
485
|
+
`resolveSerialPortPath` resolves one snapshot only. Do not cache its target across device re-enumeration.
|
|
486
|
+
|
|
487
|
+
### Upstream retry options
|
|
488
|
+
|
|
489
|
+
The constructors exclude `retryAttempts`, `retryDelayMs`, and `retryBackoffStrategy` through `WithoutUpstreamRetry`.
|
|
490
|
+
Use Effect-level `retry` and `reconnect` instead.
|
|
491
|
+
This prevents nested binding retries and competing reconnection policies.
|
|
492
|
+
The package exports narrowed native option types, `WithoutUpstreamRetry<T>`, and `UpstreamRetryOptionKey`.
|
|
493
|
+
|
|
494
|
+
## Servers and gateway
|
|
495
|
+
|
|
496
|
+
| Layer factory | Binding |
|
|
497
|
+
| ----------------------------------------------- | ------------------------------------------------------------------ |
|
|
498
|
+
| `tcpServerLayer(options, handlers)` | Native Modbus TCP server. |
|
|
499
|
+
| `serialRtuServerLayer(options, handlers)` | Native serial RTU server. |
|
|
500
|
+
| `serialAsciiServerLayer(options, handlers)` | Native serial ASCII server. |
|
|
501
|
+
| `tcpGatewayLayer(options, gatewayConfig)` | Native gateway with downstream entries and unit-to-channel routes. |
|
|
502
|
+
| `wasmWsServerLayer(options, handlers)` | Experimental browser WebSocket server. |
|
|
503
|
+
| `wasmSerialRtuServerLayer(options, handlers)` | Experimental browser serial RTU server. |
|
|
504
|
+
| `wasmSerialAsciiServerLayer(options, handlers)` | Experimental browser serial ASCII server. |
|
|
505
|
+
|
|
506
|
+
Server layers bind during layer acquisition and attempt shutdown during scope finalization.
|
|
507
|
+
Bind failures enter the typed `ModbusError` channel.
|
|
508
|
+
Shutdown failures are ignored.
|
|
509
|
+
Browser server layers also fork the binding's `serve()` loop within the layer scope and log loop failures.
|
|
510
|
+
Serial browser servers require a raw `SerialPort` from application code, not the client helper's opaque port handle.
|
|
511
|
+
Handlers use the upstream `ServerHandlers` interface.
|
|
512
|
+
See [the TCP server example](examples/tcp-server.ts), [serial server example](examples/serial-server.ts), and [gateway example](examples/tcp-gateway.ts).
|
|
638
513
|
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
Two things look alike at a call site — both read as "attach a policy here" — but behave differently, and the difference is worth internalising:
|
|
642
|
-
|
|
643
|
-
| Form | Effect on the policy already in force |
|
|
644
|
-
| ------------------------------- | ------------------------------------- |
|
|
645
|
-
| `withClient(unitId, { retry })` | **Replaces** it |
|
|
646
|
-
| `client.withRetry(policy)` | **Replaces** it |
|
|
647
|
-
| `.pipe(retryModbus(policy))` | **Wraps** it — the two nest |
|
|
648
|
-
|
|
649
|
-
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.
|
|
514
|
+
## Testing with mocks
|
|
650
515
|
|
|
651
|
-
|
|
516
|
+
Each transport tag provides `makeMockTransport(devices)(options)`.
|
|
517
|
+
The result is a scoped layer with in-memory device state and no browser, network, or serial I/O.
|
|
518
|
+
Options include the transport's connection settings, resilience settings, and mock fault hooks.
|
|
519
|
+
The abstract serial tags accept either framing's connection settings.
|
|
652
520
|
|
|
653
521
|
```ts
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
client.readHoldingRegisters(...).pipe(retryModbus(fast(3))) // 12 attempts (3 × 4)
|
|
657
|
-
```
|
|
658
|
-
|
|
659
|
-
That last form is only correct over a `RetryPolicies.none()` client — see [Retrying a transaction](#retrying-a-transaction).
|
|
660
|
-
|
|
661
|
-
### Error-aware by construction
|
|
662
|
-
|
|
663
|
-
Retrying is only correct for failures that can plausibly resolve themselves, so the policy decides per error:
|
|
664
|
-
|
|
665
|
-
| Error | Retried by default |
|
|
666
|
-
| ----------------------------- | ------------------------------------------------------------------ |
|
|
667
|
-
| `ModbusTimeoutError` | yes — slow turnaround, bus contention |
|
|
668
|
-
| `ModbusTransportError` | yes — framing/CRC corruption |
|
|
669
|
-
| `ModbusConnectionClosedError` | yes — and hands the link to the supervisor |
|
|
670
|
-
| `ModbusCircuitOpenError` | yes — refused without touching the bus, so it is cheap to wait out |
|
|
671
|
-
| `ModbusExceptionError` | only for codes `5`, `6`, `10`, `11` (busy / gateway) |
|
|
672
|
-
| `ModbusInvalidArgumentError` | no — the answer will not change |
|
|
673
|
-
| `ModbusNotConnectedError` | no |
|
|
674
|
-
| `ModbusInternalError` | no |
|
|
675
|
-
|
|
676
|
-
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`.
|
|
677
|
-
|
|
678
|
-
### Backoff and jitter
|
|
679
|
-
|
|
680
|
-
Delays follow `min(maxDelay, baseDelay × factor ** retryIndex)`, then get jittered.
|
|
681
|
-
|
|
682
|
-
**Jitter is on by default** — for `createRetryPolicy()` 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:
|
|
683
|
-
|
|
684
|
-
| `jitter` | Delay |
|
|
685
|
-
| ---------------------------- | -------------------------------------------------------- |
|
|
686
|
-
| omitted, or `true` (default) | ±20% — Effect's `0.8 – 1.2` multiplier range |
|
|
687
|
-
| `false` | exact, unrandomised delays — useful for assertable tests |
|
|
688
|
-
| `{ min: 0.5, max: 1.5 }` | custom multiplier range |
|
|
689
|
-
|
|
690
|
-
So `RetryPolicies.tcp()` waits roughly 80–120 ms before its first retry, not exactly 100 ms.
|
|
691
|
-
|
|
692
|
-
### Supervised reconnection and the circuit breaker
|
|
693
|
-
|
|
694
|
-
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:
|
|
522
|
+
import { Console, Effect } from 'effect';
|
|
523
|
+
import { TcpTransportService } from '@flux-control/effect-modbus-rs';
|
|
695
524
|
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
triggerOn: ['ModbusConnectionClosedError', 'ModbusTransportError'],
|
|
525
|
+
const layer = TcpTransportService.makeMockTransport([
|
|
526
|
+
{
|
|
527
|
+
unitId: 1,
|
|
528
|
+
coils: [],
|
|
529
|
+
discreteInputs: [],
|
|
530
|
+
holdingRegisters: [{ address: 0, default: 100 }],
|
|
531
|
+
inputRegisters: [],
|
|
704
532
|
},
|
|
705
|
-
});
|
|
706
|
-
```
|
|
707
|
-
|
|
708
|
-
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.
|
|
709
|
-
|
|
710
|
-
Without `reconnect`, a connection-level failure still publishes `Down`, but no
|
|
711
|
-
supervisor starts and no circuit breaker is enabled. Call `transport.reconnect()`
|
|
712
|
-
to recover manually. A later successful operation also restores `Connected`, so
|
|
713
|
-
a transient failure recovered by an operation retry does not leave stale state.
|
|
714
|
-
|
|
715
|
-
State transitions are published on `transport.connectionState`:
|
|
716
|
-
|
|
717
|
-
| State | Meaning |
|
|
718
|
-
| -------------- | --------------------------------------------------------------------------------------------- |
|
|
719
|
-
| `Disconnected` | Never opened, or closed. The next operation opens it lazily. |
|
|
720
|
-
| `Connected` | Usable. |
|
|
721
|
-
| `Reconnecting` | Supervisor is re-establishing the link. Operations refused. |
|
|
722
|
-
| `Down` | Link failure observed. A configured supervisor waits to probe; manual mode remains unguarded. |
|
|
723
|
-
|
|
724
|
-
```ts
|
|
725
|
-
Effect.gen(function* () {
|
|
726
|
-
yield* Stream.runForEach(SubscriptionRef.changes(transport.connectionState), (state) =>
|
|
727
|
-
Console.log(`link: ${state._tag}`),
|
|
728
|
-
);
|
|
729
|
-
});
|
|
730
|
-
```
|
|
731
|
-
|
|
732
|
-
### Connect timeout
|
|
533
|
+
])({ host: '192.0.2.1', port: 502 });
|
|
733
534
|
|
|
734
|
-
The `modbus-rs` bindings do not limit the time of a connect. A TCP connect to a host that does not answer waits until the operating system stops it. On Linux with default settings, this takes about 2 minutes. During that time, every operation joins the same pending connect.
|
|
735
|
-
|
|
736
|
-
Set `connectTimeout` to limit how long each operation waits for an open or a reconnect:
|
|
737
|
-
|
|
738
|
-
```ts
|
|
739
|
-
TcpTransportService.make({
|
|
740
|
-
host: '192.0.2.1',
|
|
741
|
-
port: 502,
|
|
742
|
-
connectTimeout: '3 seconds', // limit for each wait on an open or reconnect
|
|
743
|
-
reconnect: {},
|
|
744
|
-
});
|
|
745
|
-
```
|
|
746
|
-
|
|
747
|
-
The limit applies to each operation that waits for an open or reconnect. When it expires, that operation fails with `ModbusTimeoutError`.
|
|
748
|
-
|
|
749
|
-
The native connect cannot be cancelled, so it continues after the timeout:
|
|
750
|
-
|
|
751
|
-
- Later operations wait for the same native connect, each for at most the limit. A transport never has more than one pending native connect.
|
|
752
|
-
- When the native connect succeeds later, the transport uses it. A late reconnect that succeeds after the supervisor published `Down` publishes `Connected` at once.
|
|
753
|
-
- When the native connect fails, the next operation starts a new connect.
|
|
754
|
-
- A supervised reconnect attempt that times out is retried by the reconnect policy by default. When the attempts are exhausted, the transport publishes `Down`.
|
|
755
|
-
|
|
756
|
-
The option applies to every transport. It is unbounded by default. `requestTimeoutMs` limits each request, but it does not limit the connect.
|
|
757
|
-
|
|
758
|
-
### Retrying a transaction
|
|
759
|
-
|
|
760
|
-
`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.
|
|
761
|
-
|
|
762
|
-
```ts
|
|
763
535
|
Effect.gen(function* () {
|
|
764
|
-
const
|
|
765
|
-
|
|
766
|
-
yield* Effect.gen(function* () {
|
|
767
|
-
const current = yield* client.readHoldingRegisters({ address: 0, quantity: 2 });
|
|
768
|
-
yield* client.writeMultipleRegisters({ address: 0, values: bump(current) });
|
|
769
|
-
}).pipe(retryModbus(RetryPolicies.tcp()));
|
|
770
|
-
});
|
|
771
|
-
```
|
|
772
|
-
|
|
773
|
-
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).
|
|
774
|
-
|
|
775
|
-
See `examples/retry-policies.ts` for a runnable walkthrough.
|
|
776
|
-
|
|
777
|
-
### Why the upstream retry knobs are withheld
|
|
778
|
-
|
|
779
|
-
`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:
|
|
780
|
-
|
|
781
|
-
```ts
|
|
782
|
-
TcpTransportService.make({ host, port, retryAttempts: 3 });
|
|
783
|
-
// ^^^^^^^^^^^^^ Object literal may only specify
|
|
784
|
-
// known properties, and 'retryAttempts' does not
|
|
785
|
-
// exist in type 'TcpTransportOpenOptions & …'
|
|
786
|
-
```
|
|
787
|
-
|
|
788
|
-
They are withheld rather than merely discouraged because enabling them is never the right call under this design:
|
|
789
|
-
|
|
790
|
-
- **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.
|
|
791
|
-
- **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.
|
|
792
|
-
- **They multiply.** Neither layer knows about the other, so attempt counts compound and the two backoff curves interleave.
|
|
793
|
-
- **`retryDelayMs` is flat and unjittered** — exactly the lockstep-collision pattern `RetryPolicies.serial()` exists to break up on a shared RS-485 segment.
|
|
794
|
-
- **`retryBackoffStrategy` does nothing.** It is documented upstream as inert and reserved for future implementation, so `'exponential'` silently gets you a flat delay.
|
|
795
|
-
|
|
796
|
-
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.
|
|
797
|
-
|
|
798
|
-
The narrowed option types are exported as `RtuTransportOpenOptions`, `AsciiTransportOpenOptions`, and `TcpTransportOpenOptions`, alongside the generic `WithoutUpstreamRetry<T>` and the `UpstreamRetryOptionKey` union.
|
|
799
|
-
|
|
800
|
-
## Testing with mocks
|
|
801
|
-
|
|
802
|
-
Each transport service provides a `makeMockTransport(devices)` static method that returns an in-memory mock `Layer` — no serial port or network required.
|
|
803
|
-
|
|
804
|
-
```ts
|
|
805
|
-
import { Console, Effect } from 'effect';
|
|
806
|
-
import { RtuTransportService } from '@flux-control/effect-modbus-rs';
|
|
807
|
-
|
|
808
|
-
const device = {
|
|
809
|
-
unitId: 1,
|
|
810
|
-
coils: [
|
|
811
|
-
{ address: 0, default: true },
|
|
812
|
-
{ address: 1, default: false },
|
|
813
|
-
],
|
|
814
|
-
discreteInputs: [],
|
|
815
|
-
holdingRegisters: [
|
|
816
|
-
{ address: 0, default: 100 },
|
|
817
|
-
{ address: 1, default: 200 },
|
|
818
|
-
],
|
|
819
|
-
inputRegisters: [],
|
|
820
|
-
};
|
|
821
|
-
|
|
822
|
-
const program = Effect.gen(function* () {
|
|
823
|
-
const transport = yield* RtuTransportService;
|
|
536
|
+
const transport = yield* TcpTransportService;
|
|
824
537
|
const client = yield* transport.withClient(1);
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
});
|
|
828
|
-
|
|
829
|
-
const mockLayer = RtuTransportService.makeMockTransport([device])({
|
|
830
|
-
portPath: '/dev/ttyUSB0',
|
|
831
|
-
baudRate: 9600,
|
|
832
|
-
});
|
|
833
|
-
|
|
834
|
-
program.pipe(Effect.provide(mockLayer), Effect.scoped, Effect.runPromise);
|
|
538
|
+
yield* Console.log(yield* client.readHoldingRegisters({ address: 0, quantity: 1 }));
|
|
539
|
+
}).pipe(Effect.provide(layer), Effect.scoped, Effect.runPromise);
|
|
835
540
|
```
|
|
836
541
|
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
See `examples/rtu-mock.ts`, `examples/tcp-mock.ts`, and `examples/ascii-mock.ts` for full walkthroughs covering read, write, multi-device access, and error-case testing.
|
|
840
|
-
|
|
841
|
-
### Fault injection
|
|
542
|
+
### Device definitions
|
|
842
543
|
|
|
843
|
-
|
|
544
|
+
| Field | Contents | Default |
|
|
545
|
+
| ------------------ | ------------------------ | ------------ |
|
|
546
|
+
| `unitId` | Device unit ID. | Required. |
|
|
547
|
+
| `coils` | `{ address, default }[]` | Empty array. |
|
|
548
|
+
| `discreteInputs` | `{ address, default }[]` | Empty array. |
|
|
549
|
+
| `holdingRegisters` | `{ address, default }[]` | Empty array. |
|
|
550
|
+
| `inputRegisters` | `{ address, default }[]` | Empty array. |
|
|
844
551
|
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
552
|
+
The schema supplies defaults when decoding omitted fields.
|
|
553
|
+
The typed mock factory accepts decoded definitions, so TypeScript callers must provide those fields explicitly.
|
|
554
|
+
Address-level coil defaults are `false`; register defaults are zero.
|
|
555
|
+
Each register space extends through its highest configured address, with zero or false values in gaps.
|
|
556
|
+
Reads beyond that range fail with `ModbusInvalidArgumentError`.
|
|
557
|
+
Invalid device definitions throw a synchronous schema decode error when the factory is called.
|
|
558
|
+
FIFO and file-record operations are not implemented by the mock and fail with `ModbusInvalidArgumentError`.
|
|
849
559
|
|
|
850
|
-
|
|
560
|
+
### Fault hooks
|
|
851
561
|
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
} from '@flux-control/effect-modbus-rs';
|
|
858
|
-
|
|
859
|
-
let attempts = 0;
|
|
860
|
-
|
|
861
|
-
const mockLayer = RtuTransportService.makeMockTransport([device])({
|
|
862
|
-
portPath: '/dev/ttyUSB0',
|
|
863
|
-
baudRate: 9600,
|
|
864
|
-
retry: RetryPolicies.serial(),
|
|
865
|
-
fault: () =>
|
|
866
|
-
attempts++ < 2
|
|
867
|
-
? new ModbusTimeoutError({ message: 'no response', cause: new Error('timeout') })
|
|
868
|
-
: undefined,
|
|
869
|
-
});
|
|
870
|
-
```
|
|
871
|
-
|
|
872
|
-
### Slave device schema
|
|
873
|
-
|
|
874
|
-
| Property | Type | Default |
|
|
875
|
-
| ------------------ | ------------------------ | -------- |
|
|
876
|
-
| `unitId` | `number` | required |
|
|
877
|
-
| `coils` | `{ address, default }[]` | `[]` |
|
|
878
|
-
| `discreteInputs` | `{ address, default }[]` | `[]` |
|
|
879
|
-
| `holdingRegisters` | `{ address, default }[]` | `[]` |
|
|
880
|
-
| `inputRegisters` | `{ address, default }[]` | `[]` |
|
|
881
|
-
|
|
882
|
-
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`.
|
|
562
|
+
`fault` runs before each operation attempt.
|
|
563
|
+
`reconnectFault` runs before each reconnect attempt.
|
|
564
|
+
Return a `ModbusError` to fail that attempt, or `undefined` to allow it.
|
|
565
|
+
Because hooks run per attempt, retry and reconnection policies can be tested without hardware.
|
|
566
|
+
See [RTU mocks](examples/rtu-mock.ts), [TCP mocks](examples/tcp-mock.ts), and [ASCII mocks](examples/ascii-mock.ts).
|
|
883
567
|
|
|
884
568
|
## Development
|
|
885
569
|
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
|
890
|
-
|
|
|
891
|
-
|
|
|
892
|
-
|
|
893
|
-
|
|
570
|
+
Run these commands from the package root.
|
|
571
|
+
If a parent workspace manages dependencies, install from that workspace's root.
|
|
572
|
+
|
|
573
|
+
| Action | Command |
|
|
574
|
+
| --------------------------------- | ---------------------------- |
|
|
575
|
+
| Install | `bun install` |
|
|
576
|
+
| Check formatting | `bun run format` |
|
|
577
|
+
| Apply formatting | `bun run format:fix` |
|
|
578
|
+
| Lint | `bun run lint` |
|
|
579
|
+
| Type-check | `bun run typecheck` |
|
|
580
|
+
| Test package and native examples | `bun run test` |
|
|
581
|
+
| Build JavaScript and declarations | `bun run build` |
|
|
582
|
+
| Generate API documentation | `bun run docs` |
|
|
583
|
+
| Run a native example | `bun run examples/<name>.ts` |
|
|
584
|
+
|
|
585
|
+
Package exports use `dist/`.
|
|
586
|
+
Build before testing package imports or running the browser example.
|
|
587
|
+
Native examples can run from TypeScript with Bun.
|
|
588
|
+
The separate [browser example](examples/wasm/README.md) uses npm and Vite.
|
|
589
|
+
Package checks do not check that application.
|
|
894
590
|
|
|
895
591
|
## Source layout
|
|
896
592
|
|
|
897
|
-
|
|
898
|
-
src
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
AsciiTransportService.ts — Scoped Context.Service wrapping AsyncAsciiTransport
|
|
914
|
-
SerialTransportService.ts — Abstract serial transport (RTU/ASCII) tag
|
|
915
|
-
TcpModbusServerService.ts — tcpServerLayer
|
|
916
|
-
SerialModbusServerService.ts — serialRtuServerLayer / serialAsciiServerLayer
|
|
917
|
-
TcpGatewayService.ts — tcpGatewayLayer
|
|
918
|
-
WasmSerialPort.ts — requestSerialPort() Effect helper (browser, user-gesture gated)
|
|
919
|
-
WasmWsTransportService.ts — Scoped Context.Service wrapping WasmWsTransport (browser, WS gateway)
|
|
920
|
-
WasmRtuTransportService.ts — Scoped Context.Service wrapping WasmRtuTransport (browser, Web Serial RTU)
|
|
921
|
-
WasmAsciiTransportService.ts — Scoped Context.Service wrapping WasmAsciiTransport (browser, Web Serial ASCII)
|
|
922
|
-
WasmSerialTransportService.ts — Abstract browser serial transport (RTU/ASCII) tag
|
|
923
|
-
WasmTcpServerService.ts — wasmWsServerLayer (experimental)
|
|
924
|
-
WasmSerialModbusServerService.ts — wasmSerialRtuServerLayer / wasmSerialAsciiServerLayer (experimental)
|
|
925
|
-
examples/
|
|
926
|
-
rtu-basic.ts — RTU usage pattern
|
|
927
|
-
tcp-basic.ts — TCP usage pattern
|
|
928
|
-
ascii-basic.ts — ASCII usage pattern
|
|
929
|
-
serial-abstract.ts — Abstract serial transport (RTU or ASCII)
|
|
930
|
-
rtu-mock.ts — RTU with in-memory mock
|
|
931
|
-
tcp-mock.ts — TCP with in-memory mock (multi-device)
|
|
932
|
-
ascii-mock.ts — ASCII with in-memory mock (error-case)
|
|
933
|
-
retry-policies.ts — Transport-owned resilience: policies, overrides, transactions
|
|
934
|
-
batching.ts — Transaction batching: planners, cache, windows, spans
|
|
935
|
-
tcp-polling-stream.ts — TCP polling, reconnect, and stream
|
|
936
|
-
tcp-finalizer-reset.ts — TCP scope finalizer reset demo
|
|
937
|
-
tcp-server.ts — TCP server example
|
|
938
|
-
serial-server.ts — Serial RTU server example
|
|
939
|
-
wasm/ — Standalone runnable Vite app for the browser transports (own README, own npm project)
|
|
940
|
-
index.ts — Re-exports public API
|
|
941
|
-
```
|
|
593
|
+
- `index.ts`: Public exports and package overview.
|
|
594
|
+
- `src/*TransportService.ts`: Concrete and abstract transport services.
|
|
595
|
+
- `src/*ServerService.ts` and `src/TcpGatewayService.ts`: Scoped servers and gateway.
|
|
596
|
+
- `src/WasmSerialPort.ts`: Browser port-request helper.
|
|
597
|
+
- `src/shared-transport.ts`: Connection ownership, client caching, and raw-write guards.
|
|
598
|
+
- `src/modbus-client.ts`: Typed operations over native and browser binding clients.
|
|
599
|
+
- `src/errors.ts`: Tagged failures and binding-error conversion.
|
|
600
|
+
- `src/retry.ts` and `src/connection.ts`: Retry policies, reconnection supervisor, and circuit guard.
|
|
601
|
+
- `src/register-plan.ts`: Pure read and write transaction planners.
|
|
602
|
+
- `src/register-cache.ts`: Acknowledged-write cache and generation-based invalidation.
|
|
603
|
+
- `src/read-debouncer.ts` and `src/write-debouncer.ts`: Scoped request collection.
|
|
604
|
+
- `src/batching-client.ts`: Batching composition and per-unit registry.
|
|
605
|
+
- `src/span-attributes.ts`: Caller attribute merging.
|
|
606
|
+
- `src/mocks.ts`: Device schemas and in-memory transports.
|
|
607
|
+
- `test/`: Operation, lifecycle, retry, batching, and compile-time option checks.
|
|
608
|
+
- `examples/`: Native examples and the separate browser application.
|
|
942
609
|
|
|
943
610
|
## License
|
|
944
611
|
|