@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.
Files changed (35) hide show
  1. package/README.md +317 -20
  2. package/dist/index.d.ts +62 -1
  3. package/dist/index.js +1034 -213
  4. package/dist/src/AsciiTransportService.d.ts +14 -10
  5. package/dist/src/RtuTransportService.d.ts +14 -10
  6. package/dist/src/TcpTransportService.d.ts +14 -10
  7. package/dist/src/WasmAsciiTransportService.d.ts +15 -11
  8. package/dist/src/WasmRtuTransportService.d.ts +15 -11
  9. package/dist/src/WasmWsTransportService.d.ts +14 -10
  10. package/dist/src/batching-client.d.ts +197 -0
  11. package/dist/src/connection.d.ts +6 -3
  12. package/dist/src/mocks.d.ts +14 -8
  13. package/dist/src/modbus-client.d.ts +3 -1
  14. package/dist/src/read-debouncer.d.ts +100 -0
  15. package/dist/src/register-cache.d.ts +90 -0
  16. package/dist/src/register-plan.d.ts +212 -0
  17. package/dist/src/retry.d.ts +4 -4
  18. package/dist/src/shared-transport.d.ts +60 -4
  19. package/dist/src/span-attributes.d.ts +34 -0
  20. package/dist/src/write-debouncer.d.ts +134 -0
  21. package/dist/test/retry.test.d.ts +1 -0
  22. package/dist/test/shared-transport.test.d.ts +1 -0
  23. package/dist/test/span-attributes.test.d.ts +1 -0
  24. package/dist/test/upstream-options.test.d.ts +1 -0
  25. package/dist/test/wasm-mocks.test.d.ts +1 -0
  26. package/dist/test/write-debouncer.test.d.ts +1 -0
  27. package/package.json +5 -4
  28. /package/dist/{src/mock-options.test.d.ts → test/batching-client.test.d.ts} +0 -0
  29. /package/dist/{src/mocks.test.d.ts → test/mock-options.test.d.ts} +0 -0
  30. /package/dist/{src/modbus-client.wasm.test.d.ts → test/mocks.test.d.ts} +0 -0
  31. /package/dist/{src/resilience.test.d.ts → test/modbus-client.wasm.test.d.ts} +0 -0
  32. /package/dist/{src/retry.test.d.ts → test/read-debouncer.test.d.ts} +0 -0
  33. /package/dist/{src/shared-transport.test.d.ts → test/register-cache.test.d.ts} +0 -0
  34. /package/dist/{src/upstream-options.test.d.ts → test/register-plan.test.d.ts} +0 -0
  35. /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.catchAll((err) => Console.log(`Unhandled error: ${err.message}`)),
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
- const client = yield * transport.withClient(1);
291
- yield * client.readHoldingRegisters({ address: 0, quantity: 10 });
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
- `makeRetryPolicy(options)` builds one from scratch with the same options.
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
- const meter = yield * transport.withClient(1, { retry: RetryPolicies.serial() });
324
- const plc = yield * transport.withClient(2, { retry: RetryPolicies.serial({ maxRetries: 8 }) });
325
- const legacy = yield * transport.withClient(3, { retry: RetryPolicies.none() });
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
- yield * client.withRetry(RetryPolicies.none()).writeSingleCoil({ address: 0, value });
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 `makeRetryPolicy()` and for every template, none of which opts out. Each delay is multiplied by a random factor so a fleet of pollers does not re-hit a recovering device in lockstep:
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` | Attempts exhausted; waiting out `resetAfter` before probing again. Operations refused. |
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
- yield *
420
- Stream.runForEach(transport.connectionState.changes, (state) =>
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
- const client = yield * transport.withClient(1, { retry: RetryPolicies.none() });
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 { makeRetryPolicy, retryableExceptionCodes, RetryPolicies, retryModbus } from './src/retry';
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';