@flux-control/effect-modbus-rs 0.4.1 → 0.5.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 +317 -20
- package/dist/index.d.ts +62 -1
- package/dist/index.js +1034 -213
- package/dist/src/AsciiTransportService.d.ts +14 -10
- package/dist/src/RtuTransportService.d.ts +14 -10
- package/dist/src/TcpTransportService.d.ts +14 -10
- package/dist/src/WasmAsciiTransportService.d.ts +15 -11
- package/dist/src/WasmRtuTransportService.d.ts +15 -11
- package/dist/src/WasmWsTransportService.d.ts +14 -10
- package/dist/src/batching-client.d.ts +197 -0
- package/dist/src/connection.d.ts +6 -3
- package/dist/src/mocks.d.ts +14 -8
- package/dist/src/modbus-client.d.ts +3 -1
- package/dist/src/read-debouncer.d.ts +100 -0
- package/dist/src/register-cache.d.ts +90 -0
- package/dist/src/register-plan.d.ts +212 -0
- package/dist/src/retry.d.ts +4 -4
- package/dist/src/shared-transport.d.ts +60 -4
- package/dist/src/span-attributes.d.ts +34 -0
- package/dist/src/write-debouncer.d.ts +134 -0
- package/dist/test/retry.test.d.ts +1 -0
- package/dist/test/shared-transport.test.d.ts +1 -0
- package/dist/test/span-attributes.test.d.ts +1 -0
- package/dist/test/upstream-options.test.d.ts +1 -0
- package/dist/test/wasm-mocks.test.d.ts +1 -0
- package/dist/test/write-debouncer.test.d.ts +1 -0
- package/package.json +5 -4
- /package/dist/{src/mock-options.test.d.ts → test/batching-client.test.d.ts} +0 -0
- /package/dist/{src/mocks.test.d.ts → test/mock-options.test.d.ts} +0 -0
- /package/dist/{src/modbus-client.wasm.test.d.ts → test/mocks.test.d.ts} +0 -0
- /package/dist/{src/resilience.test.d.ts → test/modbus-client.wasm.test.d.ts} +0 -0
- /package/dist/{src/retry.test.d.ts → test/read-debouncer.test.d.ts} +0 -0
- /package/dist/{src/shared-transport.test.d.ts → test/register-cache.test.d.ts} +0 -0
- /package/dist/{src/upstream-options.test.d.ts → test/register-plan.test.d.ts} +0 -0
- /package/dist/{src/wasm-mocks.test.d.ts → test/resilience.test.d.ts} +0 -0
package/README.md
CHANGED
|
@@ -43,7 +43,7 @@ program.pipe(
|
|
|
43
43
|
ModbusExceptionError: (err) => Console.log(`Modbus exception ${err.exception}: ${err.message}`),
|
|
44
44
|
ModbusInvalidArgumentError: (err) => Console.log(`Invalid argument: ${err.message}`),
|
|
45
45
|
}),
|
|
46
|
-
Effect.
|
|
46
|
+
Effect.catch((err) => Console.log(`Unhandled error: ${err.message}`)),
|
|
47
47
|
Effect.provide(RtuTransportService.make({ portPath: '/dev/ttyUSB0', baudRate: 9600 })),
|
|
48
48
|
Effect.scoped,
|
|
49
49
|
Effect.runPromise,
|
|
@@ -254,6 +254,283 @@ See [Testing with mocks](#testing-with-mocks) for the `fault` hook and the `reco
|
|
|
254
254
|
| `writeFileRecord({ requests })` | `void` |
|
|
255
255
|
| `readDeviceIdentification({ readDeviceIdCode, objectId })` | `DeviceIdentificationResponse` |
|
|
256
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.
|
|
345
|
+
|
|
346
|
+
```mermaid
|
|
347
|
+
stateDiagram-v2
|
|
348
|
+
[*] --> Idle
|
|
349
|
+
Idle --> Open: first write arrives\n(opens window, starts maxHold clock)
|
|
350
|
+
Open --> Open: new write arrives\n(same address is superseded, timer restarts)
|
|
351
|
+
Open --> Flushing: window elapses, or maxHold reached
|
|
352
|
+
Flushing --> Idle: batch sent to device,\nall waiting callers resolve
|
|
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 `ModbusInvalidArgumentError`, whatever the second call asks for. `batchingClient` looks up, and fails the same way 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
|
+
### Shutdown
|
|
400
|
+
|
|
401
|
+
```ts
|
|
402
|
+
Effect.gen(function* () {
|
|
403
|
+
const damper = yield* transport.withBatchingClient(3);
|
|
404
|
+
yield* damper.onShutdown(damper.writeAllNow([{ address: 2000, value: 0 }]));
|
|
405
|
+
|
|
406
|
+
const motor = yield* transport.withBatchingClient(7);
|
|
407
|
+
yield* motor.onShutdown(motor.writeNow({ address: 40, value: MOTOR_STOP }));
|
|
408
|
+
});
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
A safe state is the device's own answer — zero volts for one, a stopped motor for
|
|
412
|
+
another — so it is stated on the client that addresses the device. A unit whose
|
|
413
|
+
client registers nothing is left alone. The action runs when the calling scope
|
|
414
|
+
closes, while the transport is still open, and before the client is torn down.
|
|
415
|
+
|
|
416
|
+
A failure is logged and raised as a defect. The other actions in the scope still
|
|
417
|
+
run, so a device that cannot be reached does not cost the rest of the bus its
|
|
418
|
+
turn. Wrap the action in `Effect.ignoreLogged` to accept the failure instead.
|
|
419
|
+
|
|
420
|
+
`transport.touchedUnits` reports which units a client was built for. That is
|
|
421
|
+
transport-wide diagnostic state, not device ownership, and it is not the input to
|
|
422
|
+
a shutdown policy.
|
|
423
|
+
|
|
424
|
+
### Spans
|
|
425
|
+
|
|
426
|
+
A batching client opens `modbus.write` and `modbus.read` spans, carrying:
|
|
427
|
+
|
|
428
|
+
| Attribute | Content |
|
|
429
|
+
| -------------------------- | --------------------------- |
|
|
430
|
+
| `modbus.unit_ids` | The units the batch reached |
|
|
431
|
+
| `modbus.register_count` | Registers written or read |
|
|
432
|
+
| `modbus.suppressed_count` | Writes the cache removed |
|
|
433
|
+
| `modbus.transaction_count` | Transactions issued |
|
|
434
|
+
|
|
435
|
+
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:
|
|
436
|
+
|
|
437
|
+
```ts
|
|
438
|
+
Effect.gen(function* () {
|
|
439
|
+
yield* batched.write({ address: 2000, value: 512 }, { 'app.point': 'Supply fan' });
|
|
440
|
+
});
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
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.
|
|
444
|
+
|
|
445
|
+
### Using the layers directly
|
|
446
|
+
|
|
447
|
+
The batching client is a composition, not a wall. Each layer is exported, and each works without the layer above it.
|
|
448
|
+
|
|
449
|
+
#### The planners
|
|
450
|
+
|
|
451
|
+
Pure — no Effect, no state, no I/O. Use them when you own your own scheduling and only want the packing.
|
|
452
|
+
|
|
453
|
+
```ts
|
|
454
|
+
import { planReads, planWrites } from '@flux-control/effect-modbus-rs';
|
|
455
|
+
|
|
456
|
+
planWrites([
|
|
457
|
+
{ address: 2003, value: 40 },
|
|
458
|
+
{ address: 2000, value: 10 },
|
|
459
|
+
{ address: 2010, value: 99 },
|
|
460
|
+
{ address: 2001, value: 20 },
|
|
461
|
+
{ address: 2002, value: 30 },
|
|
462
|
+
]);
|
|
463
|
+
// [
|
|
464
|
+
// { kind: "multiple", address: 2000, values: Uint16Array [10, 20, 30, 40] },
|
|
465
|
+
// { kind: "single", address: 2010, value: 99 },
|
|
466
|
+
// ]
|
|
467
|
+
|
|
468
|
+
const plan = planReads([0x0000, 0x0001, 0x0002, 0x0020, 0x0021]);
|
|
469
|
+
plan.spans; // [{ address: 0, quantity: 3 }, { address: 32, quantity: 2 }]
|
|
470
|
+
plan.locate(0x0021); // { span: 1, offset: 1 } — index back into the responses
|
|
471
|
+
plan.locate(0x0010); // undefined
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
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.
|
|
475
|
+
|
|
476
|
+
`planWrites(writes, options?)`:
|
|
477
|
+
|
|
478
|
+
| Option | Default | What it does |
|
|
479
|
+
| ---------------------- | ---------------------------------- | ------------------------------------------------------------------------------- |
|
|
480
|
+
| `maxRegistersPerWrite` | `MODBUS_MAX_WRITE_REGISTERS` (123) | Registers one FC16 may carry. A longer run splits into consecutive steps. |
|
|
481
|
+
| `minRunLength` | `2` | Shortest run that becomes FC16. Anything shorter becomes one FC06 per register. |
|
|
482
|
+
|
|
483
|
+
`planReads(addresses, options?)`:
|
|
484
|
+
|
|
485
|
+
| Option | Default | What it does |
|
|
486
|
+
| --------------------- | --------------------------------- | ------------------------------------------------------------- |
|
|
487
|
+
| `maxRegistersPerRead` | `MODBUS_MAX_READ_REGISTERS` (125) | Registers one read may return. A span never grows past this. |
|
|
488
|
+
| `maxGap` | `0` | Unrequested registers the planner may read to join two spans. |
|
|
489
|
+
|
|
490
|
+
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.
|
|
491
|
+
|
|
492
|
+
> **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.
|
|
493
|
+
|
|
494
|
+
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.
|
|
495
|
+
|
|
496
|
+
#### The debouncers and the cache
|
|
497
|
+
|
|
498
|
+
Stateful, scoped, and driven by callbacks you supply — use these to batch over a client this package did not hand out.
|
|
499
|
+
|
|
500
|
+
```ts
|
|
501
|
+
Effect.gen(function* () {
|
|
502
|
+
const writes = yield* createWriteDebouncer({
|
|
503
|
+
window: '250 millis',
|
|
504
|
+
maxHold: '1 second',
|
|
505
|
+
flush: (batch) => issueHowever(batch), // yours: cache, plan, span, write
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
// Two callers that never meet, one transaction:
|
|
509
|
+
yield* Effect.all(
|
|
510
|
+
[writes.write({ address: 2000, value: 10 }), writes.write({ address: 2001, value: 20 })],
|
|
511
|
+
{
|
|
512
|
+
concurrency: 'unbounded',
|
|
513
|
+
},
|
|
514
|
+
);
|
|
515
|
+
});
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
`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.
|
|
519
|
+
|
|
520
|
+
`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`.
|
|
521
|
+
|
|
522
|
+
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.
|
|
523
|
+
|
|
524
|
+
`createRegisterCache` returns `filter(unitId, writes)`, `observe(unitId, address, value)`, and `invalidate(unitId?)`. Call `observe` only after the device acknowledged the write: a value recorded early suppresses the retry that would have fixed it.
|
|
525
|
+
|
|
526
|
+
#### Composing them yourself
|
|
527
|
+
|
|
528
|
+
`makeBatchingClient({ unitId, client, cache, debounce, plan })` builds a `BatchingModbusClient` over any `EffectModbusClient`, which is the escape hatch when you are driving an Effect-wrapped client this package did not hand out, or a stub implementing that interface. A raw promise-based `modbus-rs` client is not accepted directly.
|
|
529
|
+
|
|
530
|
+
`createBatchingRegistry(deps)` is one level below that: it is what `withBatchingClient` and `batchingClient` are made of, including the per-unit declarations and the fiber that watches the link. You need it only if you are writing a transport of your own; both this package's transports and its mock use it. A custom transport must pass each public raw client through `registry.guardRawWrites(unitId, client)` so selecting batching also enforces the one-register-write-path rule.
|
|
531
|
+
|
|
532
|
+
`mergeSpanAttributes(sources)` is the join rule described under [Spans](#spans), exported so a custom `flush` can apply the same one.
|
|
533
|
+
|
|
257
534
|
## Error handling
|
|
258
535
|
|
|
259
536
|
Errors from the underlying Rust layer are mapped to typed `Effect` errors via `Data.TaggedError`:
|
|
@@ -287,8 +564,10 @@ const layer = TcpTransportService.make({
|
|
|
287
564
|
});
|
|
288
565
|
|
|
289
566
|
// call sites never mention retries
|
|
290
|
-
|
|
291
|
-
yield
|
|
567
|
+
Effect.gen(function* () {
|
|
568
|
+
const client = yield* transport.withClient(1);
|
|
569
|
+
yield* client.readHoldingRegisters({ address: 0, quantity: 10 });
|
|
570
|
+
});
|
|
292
571
|
```
|
|
293
572
|
|
|
294
573
|
With neither option set, a transport behaves exactly as it always has: one attempt per operation, reconnection only when you ask for it.
|
|
@@ -313,16 +592,18 @@ RetryPolicies.serial({
|
|
|
313
592
|
});
|
|
314
593
|
```
|
|
315
594
|
|
|
316
|
-
`
|
|
595
|
+
`createRetryPolicy(options)` builds one from scratch with the same options.
|
|
317
596
|
|
|
318
597
|
### Overriding per client and per operation
|
|
319
598
|
|
|
320
599
|
One bus often hosts device types that need different logic. A per-client policy **replaces** the transport's, so overrides can never multiply attempt counts:
|
|
321
600
|
|
|
322
601
|
```ts
|
|
323
|
-
|
|
324
|
-
const
|
|
325
|
-
const
|
|
602
|
+
Effect.gen(function* () {
|
|
603
|
+
const meter = yield* transport.withClient(1, { retry: RetryPolicies.serial() });
|
|
604
|
+
const plc = yield* transport.withClient(2, { retry: RetryPolicies.serial({ maxRetries: 8 }) });
|
|
605
|
+
const legacy = yield* transport.withClient(3, { retry: RetryPolicies.none() });
|
|
606
|
+
});
|
|
326
607
|
```
|
|
327
608
|
|
|
328
609
|
Clients built for the same unit ID under different policies share one underlying connection.
|
|
@@ -330,7 +611,9 @@ Clients built for the same unit ID under different policies share one underlying
|
|
|
330
611
|
`client.withRetry(policy)` does the same for a single operation:
|
|
331
612
|
|
|
332
613
|
```ts
|
|
333
|
-
|
|
614
|
+
Effect.gen(function* () {
|
|
615
|
+
yield* client.withRetry(RetryPolicies.none()).writeSingleCoil({ address: 0, value });
|
|
616
|
+
});
|
|
334
617
|
```
|
|
335
618
|
|
|
336
619
|
Resolution order is **per-operation → per-client → transport → none**. First match wins; the others are discarded, not combined.
|
|
@@ -378,7 +661,7 @@ Any of these can be switched off (`errors: { ModbusTimeoutError: false }`), swit
|
|
|
378
661
|
|
|
379
662
|
Delays follow `min(maxDelay, baseDelay × factor ** retryIndex)`, then get jittered.
|
|
380
663
|
|
|
381
|
-
**Jitter is on by default** — for `
|
|
664
|
+
**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:
|
|
382
665
|
|
|
383
666
|
| `jitter` | Delay |
|
|
384
667
|
| ---------------------------- | -------------------------------------------------------- |
|
|
@@ -406,20 +689,26 @@ TcpTransportService.make({
|
|
|
406
689
|
|
|
407
690
|
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.
|
|
408
691
|
|
|
692
|
+
Without `reconnect`, a connection-level failure still publishes `Down`, but no
|
|
693
|
+
supervisor starts and no circuit breaker is enabled. Call `transport.reconnect()`
|
|
694
|
+
to recover manually. A later successful operation also restores `Connected`, so
|
|
695
|
+
a transient failure recovered by an operation retry does not leave stale state.
|
|
696
|
+
|
|
409
697
|
State transitions are published on `transport.connectionState`:
|
|
410
698
|
|
|
411
|
-
| State | Meaning
|
|
412
|
-
| -------------- |
|
|
413
|
-
| `Disconnected` | Never opened, or closed. The next operation opens it lazily.
|
|
414
|
-
| `Connected` | Usable.
|
|
415
|
-
| `Reconnecting` | Supervisor is re-establishing the link. Operations refused.
|
|
416
|
-
| `Down` |
|
|
699
|
+
| State | Meaning |
|
|
700
|
+
| -------------- | --------------------------------------------------------------------------------------------- |
|
|
701
|
+
| `Disconnected` | Never opened, or closed. The next operation opens it lazily. |
|
|
702
|
+
| `Connected` | Usable. |
|
|
703
|
+
| `Reconnecting` | Supervisor is re-establishing the link. Operations refused. |
|
|
704
|
+
| `Down` | Link failure observed. A configured supervisor waits to probe; manual mode remains unguarded. |
|
|
417
705
|
|
|
418
706
|
```ts
|
|
419
|
-
|
|
420
|
-
Stream.runForEach(transport.connectionState
|
|
707
|
+
Effect.gen(function* () {
|
|
708
|
+
yield* Stream.runForEach(SubscriptionRef.changes(transport.connectionState), (state) =>
|
|
421
709
|
Console.log(`link: ${state._tag}`),
|
|
422
710
|
);
|
|
711
|
+
});
|
|
423
712
|
```
|
|
424
713
|
|
|
425
714
|
### Retrying a transaction
|
|
@@ -427,13 +716,14 @@ yield *
|
|
|
427
716
|
`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.
|
|
428
717
|
|
|
429
718
|
```ts
|
|
430
|
-
|
|
719
|
+
Effect.gen(function* () {
|
|
720
|
+
const client = yield* transport.withClient(1, { retry: RetryPolicies.none() });
|
|
431
721
|
|
|
432
|
-
yield *
|
|
433
|
-
Effect.gen(function* () {
|
|
722
|
+
yield* Effect.gen(function* () {
|
|
434
723
|
const current = yield* client.readHoldingRegisters({ address: 0, quantity: 2 });
|
|
435
724
|
yield* client.writeMultipleRegisters({ address: 0, values: bump(current) });
|
|
436
725
|
}).pipe(retryModbus(RetryPolicies.tcp()));
|
|
726
|
+
});
|
|
437
727
|
```
|
|
438
728
|
|
|
439
729
|
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).
|
|
@@ -568,6 +858,12 @@ src/
|
|
|
568
858
|
connection.ts — Connection state machine, reconnect supervisor, circuit breaker
|
|
569
859
|
retry.ts — Opt-in retry policies (backoff, jitter, per-error rules)
|
|
570
860
|
shared-transport.ts — Generic scoped transport lifecycle management, WithoutUpstreamRetry
|
|
861
|
+
register-plan.ts — planWrites / planReads: pure transaction packing
|
|
862
|
+
register-cache.ts — createRegisterCache: what each device already holds
|
|
863
|
+
write-debouncer.ts — createWriteDebouncer: coalesces writes that arrive separately
|
|
864
|
+
read-debouncer.ts — createReadDebouncer: collects reads that arrive separately
|
|
865
|
+
batching-client.ts — the batching client and the per-transport registry
|
|
866
|
+
span-attributes.ts — ModbusSpanAttributes and the merge rule for a batch
|
|
571
867
|
RtuTransportService.ts — Scoped Context.Service wrapping AsyncRtuTransport
|
|
572
868
|
TcpTransportService.ts — Scoped Context.Service wrapping AsyncTcpTransport
|
|
573
869
|
AsciiTransportService.ts — Scoped Context.Service wrapping AsyncAsciiTransport
|
|
@@ -591,6 +887,7 @@ examples/
|
|
|
591
887
|
tcp-mock.ts — TCP with in-memory mock (multi-device)
|
|
592
888
|
ascii-mock.ts — ASCII with in-memory mock (error-case)
|
|
593
889
|
retry-policies.ts — Transport-owned resilience: policies, overrides, transactions
|
|
890
|
+
batching.ts — Transaction batching: planners, cache, windows, spans
|
|
594
891
|
tcp-polling-stream.ts — TCP polling, reconnect, and stream
|
|
595
892
|
tcp-finalizer-reset.ts — TCP scope finalizer reset demo
|
|
596
893
|
tcp-server.ts — TCP server example
|
package/dist/index.d.ts
CHANGED
|
@@ -34,6 +34,55 @@
|
|
|
34
34
|
* - {@link wasmWsServerLayer} — Browser WS-gateway server (experimental upstream surface).
|
|
35
35
|
* - {@link wasmSerialRtuServerLayer} / {@link wasmSerialAsciiServerLayer} — Browser Web Serial servers (experimental).
|
|
36
36
|
*
|
|
37
|
+
* ## Transaction batching
|
|
38
|
+
*
|
|
39
|
+
* A caller that derives each register independently produces one transaction per
|
|
40
|
+
* register, which is the dominant cost on a half-duplex bus. Three pieces bring
|
|
41
|
+
* that count down, and each one is usable without the others.
|
|
42
|
+
*
|
|
43
|
+
* {@link planWrites} and {@link planReads} pack neighbouring addresses into the
|
|
44
|
+
* fewest transactions that cover them. They hold no state and run no I/O:
|
|
45
|
+
*
|
|
46
|
+
* ```ts
|
|
47
|
+
* planWrites([{ address: 2000, value: 10 }, { address: 2001, value: 20 }]);
|
|
48
|
+
* // [{ kind: "multiple", address: 2000, values: Uint16Array [10, 20] }]
|
|
49
|
+
* ```
|
|
50
|
+
*
|
|
51
|
+
* A planner only packs what a caller holds at one moment, and a caller with one
|
|
52
|
+
* fiber per register never holds two values at once. {@link createWriteDebouncer}
|
|
53
|
+
* and {@link createReadDebouncer} are the collection point that gives a planner
|
|
54
|
+
* something to pack, holding an operation for a window so the ones that arrive
|
|
55
|
+
* near it travel with it. Each caller still awaits its own operation.
|
|
56
|
+
*
|
|
57
|
+
* {@link createRegisterCache} drops a write whose value the device already holds.
|
|
58
|
+
* It records only what this process wrote, so it never answers a read.
|
|
59
|
+
*
|
|
60
|
+
* `transport.withBatchingClient(unitId, options)` puts the three together. It is
|
|
61
|
+
* the sibling of `withClient`, not a replacement for it: `withClient` issues the
|
|
62
|
+
* transaction a caller names, and a batching client decides the transactions for
|
|
63
|
+
* a caller that names registers instead.
|
|
64
|
+
*
|
|
65
|
+
* ```ts
|
|
66
|
+
* const client = yield* transport.withClient(3); // exact read
|
|
67
|
+
* yield* client.readHoldingRegisters({ address: 2000, quantity: 2 });
|
|
68
|
+
*
|
|
69
|
+
* const batched = yield* transport.withBatchingClient(3, { // decides the transactions
|
|
70
|
+
* debounce: { writes: { window: "250 millis", maxHold: "1 second" } },
|
|
71
|
+
* });
|
|
72
|
+
* yield* batched.write({ address: 2000, value: 512 });
|
|
73
|
+
* yield* batched.readAll([0x0000, 0x0001, 0x0020]);
|
|
74
|
+
* ```
|
|
75
|
+
*
|
|
76
|
+
* A {@link BatchingModbusClient} deliberately does not extend
|
|
77
|
+
* {@link ModbusOperations}: a raw write on the same object would go around the
|
|
78
|
+
* cache and around the batch. Once a batching client exists for a unit, raw
|
|
79
|
+
* FC06, FC16, and FC23 operations on that unit fail. The raw client remains
|
|
80
|
+
* available for exact reads, coils, and other non-register-write operations.
|
|
81
|
+
*
|
|
82
|
+
* Nothing is debounced unless `debounce` asks for it, matching the rest of this
|
|
83
|
+
* package. `writeAll` and `readAll` still plan, so a caller that holds a group of
|
|
84
|
+
* registers gets packed transactions with no window at all.
|
|
85
|
+
*
|
|
37
86
|
* ## Errors
|
|
38
87
|
*
|
|
39
88
|
* All Modbus operations fail with a {@link ModbusError} discriminated union.
|
|
@@ -95,12 +144,24 @@
|
|
|
95
144
|
*/
|
|
96
145
|
export * from './src/errors';
|
|
97
146
|
export type { EffectModbusClient } from './src/modbus-client';
|
|
98
|
-
export {
|
|
147
|
+
export { createRetryPolicy, retryableExceptionCodes, RetryPolicies, retryModbus, } from './src/retry';
|
|
99
148
|
export type { ModbusErrorTag, ModbusRetryPolicy, ModbusRetryPolicyOptions, RetryDelayOptions, RetryErrorOptions, } from './src/retry';
|
|
100
149
|
export { ConnectionState } from './src/connection';
|
|
101
150
|
export type { ReconnectOptions } from './src/connection';
|
|
102
151
|
export type { TransportResilienceOptions, UpstreamRetryOptionKey, WithoutUpstreamRetry, } from './src/shared-transport';
|
|
103
152
|
export type { ModbusOperations } from './src/modbus-client';
|
|
153
|
+
export { encodeRegisterValue, MODBUS_MAX_READ_REGISTERS, MODBUS_MAX_WRITE_REGISTERS, planReads, planWrites, } from './src/register-plan';
|
|
154
|
+
export type { MultipleWriteStep, PlanReadsOptions, PlanWritesOptions, ReadLocation, ReadPlan, ReadSpan, RegisterWrite, SingleWriteStep, WritePlanStep, } from './src/register-plan';
|
|
155
|
+
export { createRegisterCache } from './src/register-cache';
|
|
156
|
+
export type { RegisterCache, RegisterCacheFilter } from './src/register-cache';
|
|
157
|
+
export { createWriteDebouncer } from './src/write-debouncer';
|
|
158
|
+
export type { DebouncedWrite, WriteDebouncer, WriteDebouncerOptions } from './src/write-debouncer';
|
|
159
|
+
export { createReadDebouncer } from './src/read-debouncer';
|
|
160
|
+
export type { ReadDebouncer, ReadDebouncerOptions } from './src/read-debouncer';
|
|
161
|
+
export { makeBatchingClient, createBatchingRegistry } from './src/batching-client';
|
|
162
|
+
export type { BatchingClientOptions, BatchingDebounceOptions, BatchingModbusClient, BatchingRegistry, BatchingRegistryDeps, BatchingRegisterReader, } from './src/batching-client';
|
|
163
|
+
export { mergeSpanAttributes } from './src/span-attributes';
|
|
164
|
+
export type { ModbusSpanAttributes } from './src/span-attributes';
|
|
104
165
|
export { AsciiTransportService } from './src/AsciiTransportService';
|
|
105
166
|
export type { AsciiTransportOpenOptions } from './src/AsciiTransportService';
|
|
106
167
|
export { SerialTransportService } from './src/SerialTransportService';
|