@flux-control/effect-modbus-rs 0.1.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/LICENSE +674 -0
- package/README.md +291 -0
- package/dist/index.d.ts +49 -0
- package/dist/index.js +607 -0
- package/dist/src/AsciiTransportService.d.ts +46 -0
- package/dist/src/RtuTransportService.d.ts +46 -0
- package/dist/src/SerialModbusServerService.d.ts +51 -0
- package/dist/src/SerialTransportService.d.ts +45 -0
- package/dist/src/TcpGatewayService.d.ts +42 -0
- package/dist/src/TcpModbusServerService.d.ts +31 -0
- package/dist/src/TcpTransportService.d.ts +46 -0
- package/dist/src/errors.d.ts +170 -0
- package/dist/src/mocks.d.ts +134 -0
- package/dist/src/mocks.test.d.ts +1 -0
- package/dist/src/modbus-client.d.ts +182 -0
- package/dist/src/shared-transport.d.ts +64 -0
- package/package.json +59 -0
package/README.md
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
# Effect-modbus-rs
|
|
2
|
+
|
|
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
|
+
|
|
5
|
+
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
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
bun add effect-modbus-rs
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
TypeScript only while prototyping (JS consumers will be supported before 1.0).
|
|
14
|
+
|
|
15
|
+
## Quick start
|
|
16
|
+
|
|
17
|
+
### RTU (serial)
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { Console, Effect } from "effect";
|
|
21
|
+
import { RtuTransportService } from "effect-modbus-rs";
|
|
22
|
+
|
|
23
|
+
const program = Effect.gen(function* () {
|
|
24
|
+
const transport = yield* RtuTransportService;
|
|
25
|
+
const client = yield* transport.withClient(1);
|
|
26
|
+
|
|
27
|
+
const registers = yield* client.readHoldingRegisters({
|
|
28
|
+
address: 0,
|
|
29
|
+
quantity: 10,
|
|
30
|
+
});
|
|
31
|
+
console.log("Holding registers:", registers);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
program.pipe(
|
|
35
|
+
Effect.catchTags({
|
|
36
|
+
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}`),
|
|
45
|
+
}),
|
|
46
|
+
Effect.catchAll((err) => Console.log(`Unhandled error: ${err.message}`)),
|
|
47
|
+
Effect.provide(
|
|
48
|
+
RtuTransportService.Default({ portPath: "/dev/ttyUSB0", baudRate: 9600 }),
|
|
49
|
+
),
|
|
50
|
+
Effect.scoped,
|
|
51
|
+
Effect.runPromise,
|
|
52
|
+
);
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### TCP
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
import { Effect } from "effect";
|
|
59
|
+
import { TcpTransportService } from "effect-modbus-rs";
|
|
60
|
+
|
|
61
|
+
const program = Effect.gen(function* () {
|
|
62
|
+
const transport = yield* TcpTransportService;
|
|
63
|
+
const client = yield* transport.withClient(1);
|
|
64
|
+
const coils = yield* client.readCoils({ address: 0, quantity: 8 });
|
|
65
|
+
console.log("Coils:", coils);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
program.pipe(
|
|
69
|
+
Effect.provide(
|
|
70
|
+
TcpTransportService.Default({ host: "192.168.1.100", port: 502 }),
|
|
71
|
+
),
|
|
72
|
+
Effect.scoped,
|
|
73
|
+
Effect.runPromise,
|
|
74
|
+
);
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### ASCII
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
import { Effect } from "effect";
|
|
81
|
+
import { AsciiTransportService } from "effect-modbus-rs";
|
|
82
|
+
|
|
83
|
+
const program = Effect.gen(function* () {
|
|
84
|
+
const transport = yield* AsciiTransportService;
|
|
85
|
+
const client = yield* transport.withClient(1);
|
|
86
|
+
const registers = yield* client.readInputRegisters({
|
|
87
|
+
address: 0,
|
|
88
|
+
quantity: 5,
|
|
89
|
+
});
|
|
90
|
+
console.log("Input registers:", registers);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
program.pipe(
|
|
94
|
+
Effect.provide(
|
|
95
|
+
AsciiTransportService.Default({ portPath: "/dev/ttyUSB0", baudRate: 9600 }),
|
|
96
|
+
),
|
|
97
|
+
Effect.scoped,
|
|
98
|
+
Effect.runPromise,
|
|
99
|
+
);
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Transports
|
|
103
|
+
|
|
104
|
+
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
|
+
|
|
106
|
+
| Service | Options | Connection |
|
|
107
|
+
|---------|---------|------------|
|
|
108
|
+
| `RtuTransportService` | `{ portPath, baudRate, ... }` | `AsyncRtuTransport.open()` |
|
|
109
|
+
| `TcpTransportService` | `{ host, port, ... }` | `AsyncTcpTransport.connect()` |
|
|
110
|
+
| `AsciiTransportService` | `{ portPath, baudRate, ... }` | `AsyncAsciiTransport.open()` |
|
|
111
|
+
|
|
112
|
+
All transport options types are re-exported from `modbus-rs`.
|
|
113
|
+
|
|
114
|
+
### Abstract serial transport
|
|
115
|
+
|
|
116
|
+
`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
|
+
|
|
118
|
+
```ts
|
|
119
|
+
import { Console, Effect } from "effect";
|
|
120
|
+
import { SerialTransportService } from "effect-modbus-rs";
|
|
121
|
+
|
|
122
|
+
const program = Effect.gen(function* () {
|
|
123
|
+
const transport = yield* SerialTransportService;
|
|
124
|
+
const client = yield* transport.withClient(1);
|
|
125
|
+
const coils = yield* client.readCoils({ address: 0, quantity: 2 });
|
|
126
|
+
console.log("Coils:", coils);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// RTU framing
|
|
130
|
+
program.pipe(
|
|
131
|
+
Effect.provide(SerialTransportService.fromRtu({ portPath: "/dev/ttyUSB0", baudRate: 9600 })),
|
|
132
|
+
Effect.scoped,
|
|
133
|
+
Effect.runPromise,
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
// Or ASCII framing
|
|
137
|
+
// program.pipe(
|
|
138
|
+
// Effect.provide(SerialTransportService.fromAscii({ portPath: "/dev/ttyUSB0", baudRate: 9600 })),
|
|
139
|
+
// Effect.scoped,
|
|
140
|
+
// Effect.runPromise,
|
|
141
|
+
// );
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
It also supports `makeMockTransport` for testing:
|
|
145
|
+
|
|
146
|
+
## Client API
|
|
147
|
+
|
|
148
|
+
`transport.withClient(unitId)` returns an `EffectModbusClient` — a typed wrapper around the raw modbus-rs client. All methods return `Effect.Effect<T, ModbusError>`.
|
|
149
|
+
|
|
150
|
+
### Registers
|
|
151
|
+
|
|
152
|
+
| Method | Returns |
|
|
153
|
+
|--------|---------|
|
|
154
|
+
| `readHoldingRegisters({ address, quantity })` | `number[]` |
|
|
155
|
+
| `readInputRegisters({ address, quantity })` | `number[]` |
|
|
156
|
+
| `writeSingleRegister({ address, value })` | `void` |
|
|
157
|
+
| `writeMultipleRegisters({ address, values })` | `void` |
|
|
158
|
+
| `readWriteMultipleRegisters({ readAddress, readQuantity, writeAddress, writeValues })` | `number[]` |
|
|
159
|
+
|
|
160
|
+
### Coils / discrete inputs
|
|
161
|
+
|
|
162
|
+
| Method | Returns |
|
|
163
|
+
|--------|---------|
|
|
164
|
+
| `readCoils({ address, quantity })` | `boolean[]` |
|
|
165
|
+
| `writeSingleCoil({ address, value })` | `void` |
|
|
166
|
+
| `writeMultipleCoils({ address, values })` | `void` |
|
|
167
|
+
| `readDiscreteInputs({ address, quantity })` | `boolean[]` |
|
|
168
|
+
|
|
169
|
+
### Diagnostics & file access
|
|
170
|
+
|
|
171
|
+
| Method | Returns |
|
|
172
|
+
|--------|---------|
|
|
173
|
+
| `readExceptionStatus()` | `number` |
|
|
174
|
+
| `diagnostics({ subFunction, data })` | `DiagnosticsResponse` |
|
|
175
|
+
| `readFifoQueue({ address })` | `FifoQueueResponse` |
|
|
176
|
+
| `readFileRecord({ requests })` | `number[][]` |
|
|
177
|
+
| `writeFileRecord({ requests })` | `void` |
|
|
178
|
+
| `readDeviceIdentification({ readDeviceIdCode, objectId })` | `DeviceIdentificationResponse` |
|
|
179
|
+
|
|
180
|
+
## Error handling
|
|
181
|
+
|
|
182
|
+
Errors from the underlying Rust layer are mapped to typed `Effect` errors via `Data.TaggedError`:
|
|
183
|
+
|
|
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 |
|
|
193
|
+
|
|
194
|
+
Handle with `Effect.catchTags`. The `ModbusError` union type covers all seven variants.
|
|
195
|
+
|
|
196
|
+
## Testing with mocks
|
|
197
|
+
|
|
198
|
+
Each transport service provides a `makeMockTransport(devices)` static method that returns an in-memory mock `Layer` — no serial port or network required.
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
import { Console, Effect } from "effect";
|
|
202
|
+
import { RtuTransportService } from "effect-modbus-rs";
|
|
203
|
+
|
|
204
|
+
const device = {
|
|
205
|
+
unitId: 1,
|
|
206
|
+
coils: [
|
|
207
|
+
{ address: 0, default: true },
|
|
208
|
+
{ address: 1, default: false },
|
|
209
|
+
],
|
|
210
|
+
discreteInputs: [],
|
|
211
|
+
holdingRegisters: [
|
|
212
|
+
{ address: 0, default: 100 },
|
|
213
|
+
{ address: 1, default: 200 },
|
|
214
|
+
],
|
|
215
|
+
inputRegisters: [],
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const program = Effect.gen(function* () {
|
|
219
|
+
const transport = yield* RtuTransportService;
|
|
220
|
+
const client = yield* transport.withClient(1);
|
|
221
|
+
const coils = yield* client.readCoils({ address: 0, quantity: 2 });
|
|
222
|
+
console.log("Coils:", coils);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const mockLayer = RtuTransportService.makeMockTransport([device])({
|
|
226
|
+
portPath: "/dev/ttyUSB0",
|
|
227
|
+
baudRate: 9600,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
program.pipe(
|
|
231
|
+
Effect.provide(mockLayer),
|
|
232
|
+
Effect.scoped,
|
|
233
|
+
Effect.runPromise,
|
|
234
|
+
);
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
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.
|
|
238
|
+
|
|
239
|
+
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.
|
|
240
|
+
|
|
241
|
+
### Slave device schema
|
|
242
|
+
|
|
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 }[]` | `[]` |
|
|
250
|
+
|
|
251
|
+
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
|
+
|
|
253
|
+
## Development
|
|
254
|
+
|
|
255
|
+
| Action | Command |
|
|
256
|
+
|--------|---------|
|
|
257
|
+
| Install | `bun install` |
|
|
258
|
+
| Type-check | `bun run typecheck` |
|
|
259
|
+
| Test | `bun test` |
|
|
260
|
+
| Run example | `bun run examples/<name>.ts` |
|
|
261
|
+
|
|
262
|
+
No build step — `noEmit` is on; Bun runs `.ts` directly.
|
|
263
|
+
|
|
264
|
+
## Source layout
|
|
265
|
+
|
|
266
|
+
```
|
|
267
|
+
src/
|
|
268
|
+
errors.ts — Data.TaggedError types + toModbusError converter
|
|
269
|
+
modbus-client.ts — EffectModbusClient interface + factory
|
|
270
|
+
mocks.ts — Schema-validated mock transport + slave device definitions
|
|
271
|
+
shared-transport.ts — Generic scoped transport lifecycle management
|
|
272
|
+
RtuTransportService.ts — Scoped Effect.Service wrapping AsyncRtuTransport
|
|
273
|
+
TcpTransportService.ts — Scoped Effect.Service wrapping AsyncTcpTransport
|
|
274
|
+
AsciiTransportService.ts — Scoped Effect.Service wrapping AsyncAsciiTransport
|
|
275
|
+
SerialTransportService.ts — Abstract serial transport (RTU/ASCII) tag
|
|
276
|
+
examples/
|
|
277
|
+
rtu-basic.ts — RTU usage pattern
|
|
278
|
+
tcp-basic.ts — TCP usage pattern
|
|
279
|
+
ascii-basic.ts — ASCII usage pattern
|
|
280
|
+
serial-abstract.ts — Abstract serial transport (RTU or ASCII)
|
|
281
|
+
rtu-mock.ts — RTU with in-memory mock
|
|
282
|
+
tcp-mock.ts — TCP with in-memory mock (multi-device)
|
|
283
|
+
ascii-mock.ts — ASCII with in-memory mock (error-case)
|
|
284
|
+
tcp-polling-stream.ts — TCP polling, reconnect, and stream
|
|
285
|
+
tcp-finalizer-reset.ts — TCP scope finalizer reset demo
|
|
286
|
+
index.ts — Re-exports public API
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
## License
|
|
290
|
+
|
|
291
|
+
GPL-3.0
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* # effect-modbus-rs
|
|
3
|
+
*
|
|
4
|
+
* Type-safe Modbus communication via Effect-TS, wrapping the `modbus-rs`
|
|
5
|
+
* npm bindings (Rust `napi-rs` under the hood).
|
|
6
|
+
*
|
|
7
|
+
* ## Transport services
|
|
8
|
+
*
|
|
9
|
+
* - {@link SerialTransportService} — Abstract serial transport (ASCII or RTU).
|
|
10
|
+
* - {@link RtuTransportService} — Serial RTU transport (RS-232/485).
|
|
11
|
+
* - {@link AsciiTransportService} — Serial ASCII transport.
|
|
12
|
+
* - {@link TcpTransportService} — TCP/IP transport (Modbus/TCP).
|
|
13
|
+
*
|
|
14
|
+
* ## Server layers
|
|
15
|
+
*
|
|
16
|
+
* Run a server layer with {@link Layer.launch} and execute with a runtime:
|
|
17
|
+
*
|
|
18
|
+
* ```ts
|
|
19
|
+
* Layer.launch(tcpServerLayer({ host: "0.0.0.0", port: 502, unitId: 1 }, handlers)).pipe(Effect.runPromise)
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* - {@link serialRtuServerLayer} — Serial RTU server.
|
|
23
|
+
* - {@link serialAsciiServerLayer} — Serial ASCII server.
|
|
24
|
+
* - {@link tcpServerLayer} — TCP server.
|
|
25
|
+
* - {@link tcpGatewayLayer} — TCP gateway.
|
|
26
|
+
*
|
|
27
|
+
* ## Errors
|
|
28
|
+
*
|
|
29
|
+
* All Modbus operations fail with a {@link ModbusError} discriminated union.
|
|
30
|
+
* Use `Effect.catchTags` to handle specific variants:
|
|
31
|
+
*
|
|
32
|
+
* ```ts
|
|
33
|
+
* Effect.catchTags(effect, {
|
|
34
|
+
* ModbusTimeoutError: ...,
|
|
35
|
+
* ModbusTransportError: ...,
|
|
36
|
+
* })
|
|
37
|
+
* ```
|
|
38
|
+
*
|
|
39
|
+
* @module effect-modbus-rs
|
|
40
|
+
*/
|
|
41
|
+
export * from "./src/errors";
|
|
42
|
+
export { AsciiTransportService } from "./src/AsciiTransportService";
|
|
43
|
+
export { SerialTransportService } from "./src/SerialTransportService";
|
|
44
|
+
export { TcpTransportService } from "./src/TcpTransportService";
|
|
45
|
+
export { RtuTransportService } from "./src/RtuTransportService";
|
|
46
|
+
export { serialRtuServerLayer, serialAsciiServerLayer } from "./src/SerialModbusServerService";
|
|
47
|
+
export { tcpServerLayer } from "./src/TcpModbusServerService";
|
|
48
|
+
export { tcpGatewayLayer } from "./src/TcpGatewayService";
|
|
49
|
+
export type { CoilDefinition, DiscreteInputDefinition, RegisterDefinition, SlaveDeviceDefinition, SlaveDeviceDefinitions, } from "./src/mocks";
|