@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/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
@@ -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";