@flux-control/effect-modbus-rs 0.4.1 → 0.6.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 (36) hide show
  1. package/README.md +343 -20
  2. package/dist/index.d.ts +62 -1
  3. package/dist/index.js +1046 -216
  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/errors.d.ts +3 -0
  13. package/dist/src/mocks.d.ts +14 -8
  14. package/dist/src/modbus-client.d.ts +3 -1
  15. package/dist/src/read-debouncer.d.ts +100 -0
  16. package/dist/src/register-cache.d.ts +90 -0
  17. package/dist/src/register-plan.d.ts +212 -0
  18. package/dist/src/retry.d.ts +4 -4
  19. package/dist/src/shared-transport.d.ts +79 -5
  20. package/dist/src/span-attributes.d.ts +34 -0
  21. package/dist/src/write-debouncer.d.ts +134 -0
  22. package/dist/test/retry.test.d.ts +1 -0
  23. package/dist/test/shared-transport.test.d.ts +1 -0
  24. package/dist/test/span-attributes.test.d.ts +1 -0
  25. package/dist/test/upstream-options.test.d.ts +1 -0
  26. package/dist/test/wasm-mocks.test.d.ts +1 -0
  27. package/dist/test/write-debouncer.test.d.ts +1 -0
  28. package/package.json +5 -4
  29. /package/dist/{src/mock-options.test.d.ts → test/batching-client.test.d.ts} +0 -0
  30. /package/dist/{src/mocks.test.d.ts → test/mock-options.test.d.ts} +0 -0
  31. /package/dist/{src/modbus-client.wasm.test.d.ts → test/mocks.test.d.ts} +0 -0
  32. /package/dist/{src/resilience.test.d.ts → test/modbus-client.wasm.test.d.ts} +0 -0
  33. /package/dist/{src/retry.test.d.ts → test/read-debouncer.test.d.ts} +0 -0
  34. /package/dist/{src/shared-transport.test.d.ts → test/register-cache.test.d.ts} +0 -0
  35. /package/dist/{src/upstream-options.test.d.ts → test/register-plan.test.d.ts} +0 -0
  36. /package/dist/{src/wasm-mocks.test.d.ts → test/resilience.test.d.ts} +0 -0
@@ -0,0 +1,100 @@
1
+ /**
2
+ * @fileoverview Collects register reads that arrive separately into one span.
3
+ *
4
+ * `planReads` merges the addresses a caller holds at one moment. A caller with an
5
+ * accessor per parameter never holds two addresses at the same moment, so a
6
+ * planner alone would force every such caller to be rewritten around a batch API.
7
+ *
8
+ * This module collects the addresses on time instead, so a caller keeps its
9
+ * per-parameter accessors and still gets one transaction per span.
10
+ *
11
+ * The read debouncer is deliberately simpler than the write debouncer. There is
12
+ * no supersede rule, because a second request for an address is not a newer
13
+ * value, it is a second reader. There is no cache, because this package records
14
+ * only what it wrote. There is no ceiling on the hold, because the window does
15
+ * not restart: it opens on the first arrival of a batch and expires on time,
16
+ * which a stream of arrivals can never push out.
17
+ *
18
+ * @module
19
+ */
20
+ import { Duration, Effect, type Scope } from 'effect';
21
+ import { type ModbusError } from './errors';
22
+ import { type PlanReadsOptions, type ReadSpan } from './register-plan';
23
+ /** Options for {@link createReadDebouncer}. */
24
+ export interface ReadDebouncerOptions {
25
+ /**
26
+ * How long addresses are collected before the spans are read.
27
+ *
28
+ * The window opens on the first arrival and does not restart, so it bounds the
29
+ * latency a reader pays. One transaction is the unit to compare it against:
30
+ * a window far shorter than a transaction collects nothing, and a window much
31
+ * longer than one delays every reader for a batch it did not need.
32
+ *
33
+ * A window of zero disables the debouncer: every read is issued on its own,
34
+ * with no timer and no batch.
35
+ */
36
+ readonly window: Duration.Input;
37
+ /** Limits handed to `planReads`. */
38
+ readonly plan?: PlanReadsOptions;
39
+ /**
40
+ * Reads the planned spans, in order.
41
+ *
42
+ * Must return one response per span, each holding that span's `quantity`
43
+ * registers. The callback owns the function code: a caller that reads input
44
+ * registers rather than holding registers builds a second debouncer.
45
+ */
46
+ fetch(spans: ReadonlyArray<ReadSpan>): Effect.Effect<ReadonlyArray<Uint16Array>, ModbusError>;
47
+ }
48
+ /** A collection point for reads that arrive separately. */
49
+ export interface ReadDebouncer {
50
+ /** Collects an address for the window, then returns its value. */
51
+ read(address: number): Effect.Effect<number, ModbusError>;
52
+ /**
53
+ * Adds an address to the batch and reads immediately, rather than waiting.
54
+ *
55
+ * Every reader already collected is answered by the same transaction, so no
56
+ * reader waits longer because of this call.
57
+ */
58
+ readNow(address: number): Effect.Effect<number, ModbusError>;
59
+ /**
60
+ * Collects several addresses for the window, as one reader, and returns their
61
+ * values in the order asked for.
62
+ *
63
+ * A reader that already holds every address does not need a collection point,
64
+ * only a planner. With a window of zero the group is planned and read at once,
65
+ * so a caller of this method gets one transaction per span with no debouncer
66
+ * at all.
67
+ */
68
+ readAll(addresses: ReadonlyArray<number>): Effect.Effect<ReadonlyArray<number>, ModbusError>;
69
+ /** Adds several addresses to the batch and reads immediately. */
70
+ readAllNow(addresses: ReadonlyArray<number>): Effect.Effect<ReadonlyArray<number>, ModbusError>;
71
+ /**
72
+ * Reads whatever is collected, now.
73
+ *
74
+ * Never fails: the outcome goes to the readers waiting on the batch, not to
75
+ * whoever asked for the flush.
76
+ */
77
+ readonly flush: Effect.Effect<void>;
78
+ /** Addresses currently collected. Intended for tests. */
79
+ readonly pending: number;
80
+ }
81
+ /**
82
+ * Creates a read debouncer bound to the current scope.
83
+ *
84
+ * As with the write debouncer, the read runs in that scope. A reader interrupted
85
+ * while waiting must not take the batch down with it.
86
+ *
87
+ * When the scope closes, readers still waiting are interrupted.
88
+ *
89
+ * @param options - The window, the planner limits, and the callback that reads.
90
+ * @returns The debouncer.
91
+ *
92
+ * @example
93
+ * const debouncer = yield* createReadDebouncer({
94
+ * window: '5 millis',
95
+ * fetch: (spans) => Effect.forEach(spans, (span) => client.readHoldingRegisters(span)),
96
+ * });
97
+ * // 49 accessors, three transactions:
98
+ * yield* Effect.forEach(addresses, debouncer.read, { concurrency: 'unbounded' });
99
+ */
100
+ export declare const createReadDebouncer: (options: ReadDebouncerOptions) => Effect.Effect<ReadDebouncer, never, Scope.Scope>;
@@ -0,0 +1,90 @@
1
+ /**
2
+ * @fileoverview A record of what each device already holds, used to drop writes
3
+ * that would change nothing.
4
+ *
5
+ * A caller that recomputes an output on every cycle proposes the same value most
6
+ * of the time. In steady state roughly four out of five proposals are a repeat of
7
+ * what the register already holds, and on a half-duplex bus each one costs a
8
+ * turnaround that carries no information.
9
+ *
10
+ * @module
11
+ */
12
+ import { type RegisterWrite } from './register-plan';
13
+ /** The result of {@link RegisterCache.filter}. */
14
+ export interface RegisterCacheFilter {
15
+ /** Writes that change a register, in the order they were proposed. */
16
+ readonly pending: ReadonlyArray<RegisterWrite>;
17
+ /** Writes the cache removed because the device already holds the value. */
18
+ readonly suppressed: ReadonlyArray<RegisterWrite>;
19
+ }
20
+ /**
21
+ * A belief about the contents of the registers on a bus.
22
+ *
23
+ * The key is the unit and the address together, so one cache serves every device
24
+ * on a transport. That is deliberate: the cache is a belief about a physical
25
+ * device, and there is one physical device. Two caches on one unit disagree, and
26
+ * the one that is wrong suppresses a write that the device needed.
27
+ *
28
+ * The cache is not a read cache. It records only what this process wrote, so it
29
+ * never answers a read and never claims to know a register nobody has written.
30
+ */
31
+ export interface RegisterCache {
32
+ /**
33
+ * Splits proposed writes into the ones that change something and the ones that
34
+ * do not.
35
+ *
36
+ * When an address appears more than once, the last write wins, which is the
37
+ * same rule `planWrites` applies. The earlier proposals are reported as
38
+ * suppressed, so the counts still add up to the number of proposals.
39
+ *
40
+ * @param unitId - Unit the writes are addressed to.
41
+ * @param writes - Proposed writes.
42
+ * @returns The writes to issue, and the ones the cache removed.
43
+ * @throws RangeError - An address or a value is out of range.
44
+ */
45
+ filter(unitId: number, writes: ReadonlyArray<RegisterWrite>): RegisterCacheFilter;
46
+ /**
47
+ * How many times what this cache believes about a unit has been discarded.
48
+ *
49
+ * Read it before issuing a write and hand it back to {@link observe}. The
50
+ * number changes when the unit is invalidated, so an observation that was
51
+ * already in flight can tell that it is describing a device nobody knows
52
+ * anything about any more.
53
+ */
54
+ generationOf(unitId: number): number;
55
+ /**
56
+ * Records that a register now holds a value.
57
+ *
58
+ * Call this only after the device acknowledged the write. A value recorded
59
+ * before the acknowledgement suppresses the retry that would have fixed it.
60
+ *
61
+ * Pass the `generation` read before the write was issued. An invalidation that
62
+ * lands while a write is on the wire has to win: the write may be the last one
63
+ * the device took before it power-cycled, and re-recording it afterwards would
64
+ * suppress exactly the write that restores the register. Omitting `generation`
65
+ * records unconditionally.
66
+ */
67
+ observe(unitId: number, address: number, value: number, generation?: number): void;
68
+ /**
69
+ * Forgets what a unit holds, or what every unit holds when `unitId` is omitted.
70
+ *
71
+ * A failed transaction and a reconnect both mean the state of the device is
72
+ * unknown: it may have power-cycled and reset its outputs. A stale belief then
73
+ * suppresses exactly the write that would restore it.
74
+ */
75
+ invalidate(unitId?: number): void;
76
+ /** Registers the cache currently holds a value for. Intended for tests. */
77
+ readonly size: number;
78
+ }
79
+ /**
80
+ * Creates an empty {@link RegisterCache}.
81
+ *
82
+ * The cache is synchronous and holds no scope. Its lifetime is the transport's:
83
+ * one cache per bus, shared by every client derived from it.
84
+ *
85
+ * @example
86
+ * const cache = createRegisterCache();
87
+ * const { pending, suppressed } = cache.filter(3, [{ address: 2000, value: 512 }]);
88
+ * // pending: the write; suppressed: empty, since nothing is known yet
89
+ */
90
+ export declare const createRegisterCache: () => RegisterCache;
@@ -0,0 +1,212 @@
1
+ /**
2
+ * @fileoverview Pure planners that turn a set of register addresses into the
3
+ * smallest set of Modbus transactions that covers them.
4
+ *
5
+ * A caller that derives each register independently — one fiber per output, one
6
+ * accessor per parameter — naturally produces one transaction per register. On a
7
+ * half-duplex multi-drop bus that is the dominant cost: every transaction is a
8
+ * turnaround, and the registers a caller wants are usually neighbours.
9
+ *
10
+ * These functions hold no state, run no I/O, and return no `Effect`. They are the
11
+ * bottom layer of the batching stack and are usable on their own — a caller that
12
+ * already owns its scheduling can plan the transactions here and issue them with
13
+ * a plain {@link EffectModbusClient}.
14
+ *
15
+ * @module
16
+ */
17
+ /**
18
+ * Registers one FC16 (write multiple registers) transaction can carry.
19
+ *
20
+ * The limit follows from the PDU: 253 bytes, less the function code, the starting
21
+ * address, the quantity, and the byte count, leaves 246 bytes of payload.
22
+ *
23
+ * Many devices stop at a lower number than the specification allows. Pass
24
+ * {@link PlanWritesOptions.maxRegistersPerWrite} when the device says so.
25
+ */
26
+ export declare const MODBUS_MAX_WRITE_REGISTERS = 123;
27
+ /**
28
+ * Registers one FC03/FC04 (read holding/input registers) transaction can return.
29
+ *
30
+ * As with {@link MODBUS_MAX_WRITE_REGISTERS}, a device may allow fewer. Pass
31
+ * {@link PlanReadsOptions.maxRegistersPerRead} when it does.
32
+ */
33
+ export declare const MODBUS_MAX_READ_REGISTERS = 125;
34
+ /** One requested register write, before planning. */
35
+ export interface RegisterWrite {
36
+ /** Register address. */
37
+ readonly address: number;
38
+ /**
39
+ * Value to write.
40
+ *
41
+ * Accepted as unsigned (`0` to `65535`) or as two's-complement signed
42
+ * (`-32768` to `-1`). A signed value is encoded to its unsigned form, which is
43
+ * what the wire carries either way.
44
+ */
45
+ readonly value: number;
46
+ }
47
+ /** A planned FC06 (write single register) transaction. */
48
+ export interface SingleWriteStep {
49
+ readonly kind: 'single';
50
+ /** Register address. */
51
+ readonly address: number;
52
+ /** Value to write, encoded unsigned. */
53
+ readonly value: number;
54
+ }
55
+ /** A planned FC16 (write multiple registers) transaction. */
56
+ export interface MultipleWriteStep {
57
+ readonly kind: 'multiple';
58
+ /** Address of the first register in the run. */
59
+ readonly address: number;
60
+ /** Values for the run, in address order. */
61
+ readonly values: Uint16Array;
62
+ }
63
+ /**
64
+ * One transaction in a write plan.
65
+ *
66
+ * Each step maps onto exactly one client call — `single` onto
67
+ * `writeSingleRegister`, `multiple` onto `writeMultipleRegisters` — so a caller
68
+ * can drive a plan without re-deriving the function code.
69
+ */
70
+ export type WritePlanStep = SingleWriteStep | MultipleWriteStep;
71
+ /** Options for {@link planWrites}. */
72
+ export interface PlanWritesOptions {
73
+ /**
74
+ * Registers one FC16 transaction may carry. A longer run is split into
75
+ * consecutive steps of at most this many registers.
76
+ *
77
+ * @defaultValue {@link MODBUS_MAX_WRITE_REGISTERS} (123)
78
+ */
79
+ readonly maxRegistersPerWrite?: number;
80
+ /**
81
+ * Shortest run that becomes one FC16 step. A run shorter than this becomes one
82
+ * FC06 step per register.
83
+ *
84
+ * Some devices answer a repeated FC06 faster than a short FC16, so the trade is
85
+ * device-specific. Raise this to prefer FC06; set it to `1` to send every run
86
+ * as FC16.
87
+ *
88
+ * @defaultValue 2
89
+ */
90
+ readonly minRunLength?: number;
91
+ }
92
+ /** Options for {@link planReads}. */
93
+ export interface PlanReadsOptions {
94
+ /**
95
+ * Registers one read transaction may return. A span never grows past this.
96
+ *
97
+ * @defaultValue {@link MODBUS_MAX_READ_REGISTERS} (125)
98
+ */
99
+ readonly maxRegistersPerRead?: number;
100
+ /**
101
+ * Unrequested registers the planner may read to join two spans into one.
102
+ *
103
+ * At the default of `0`, a span holds only contiguous requested addresses.
104
+ * Raising it trades reading registers nobody asked for against a lower
105
+ * transaction count.
106
+ *
107
+ * CAUTION: A gap can cover an address the device does not implement. Such a
108
+ * span fails with `ILLEGAL_DATA_ADDRESS` and takes every address in it down,
109
+ * including the ones that would have answered. Raise this only against a
110
+ * register map that says the gap is readable.
111
+ *
112
+ * @defaultValue 0
113
+ */
114
+ readonly maxGap?: number;
115
+ }
116
+ /** One planned read transaction. */
117
+ export interface ReadSpan {
118
+ /** Address of the first register in the span. */
119
+ readonly address: number;
120
+ /** Registers to read, starting at {@link address}. */
121
+ readonly quantity: number;
122
+ }
123
+ /** Where one address sits in the responses to a {@link ReadPlan}. */
124
+ export interface ReadLocation {
125
+ /** Index into {@link ReadPlan.spans}, and into the responses to them. */
126
+ readonly span: number;
127
+ /** Index of the register within that span's response. */
128
+ readonly offset: number;
129
+ }
130
+ /** The result of {@link planReads}. */
131
+ export interface ReadPlan {
132
+ /** Transactions to issue, in ascending address order. */
133
+ readonly spans: ReadonlyArray<ReadSpan>;
134
+ /**
135
+ * Maps an address back to its position in the responses.
136
+ *
137
+ * Answers for every address a span covers, which includes the addresses a
138
+ * `maxGap` merge pulled in. Returns `undefined` for an address no span covers.
139
+ */
140
+ locate(address: number): ReadLocation | undefined;
141
+ }
142
+ /**
143
+ * Encodes a value to its unsigned 16-bit form, rejecting one that does not fit.
144
+ *
145
+ * Both step kinds carry the encoded value, so a plan writes the same bits whether
146
+ * the planner grouped an address into an FC06 or an FC16. Leaving the FC06 path
147
+ * unencoded would let `Uint16Array` truncate one path and not the other.
148
+ *
149
+ * Anything that compares a proposed value against a value already on the device
150
+ * must encode first, for the same reason: `-1` and `65535` are the same register
151
+ * contents, and a comparison that says otherwise rewrites the register forever.
152
+ *
153
+ * @param value - Unsigned (`0` to `65535`) or two's-complement signed (`-32768` to `-1`).
154
+ * @returns The value as an unsigned 16-bit number.
155
+ * @throws RangeError - The value is not an integer, or does not fit 16 bits.
156
+ */
157
+ export declare const encodeRegisterValue: (value: number) => number;
158
+ /**
159
+ * Packs register writes into the fewest transactions that cover them.
160
+ *
161
+ * The planner sorts by address, groups contiguous addresses into runs, splits
162
+ * each run at `maxRegistersPerWrite`, and emits FC16 for a run of at least
163
+ * `minRunLength` registers or FC06 for anything shorter.
164
+ *
165
+ * When the same address appears more than once, the last write wins. This
166
+ * matches a caller that supersedes a pending value rather than queueing behind
167
+ * it, and it is the only rule under which the plan is order-independent for
168
+ * distinct addresses.
169
+ *
170
+ * @param writes - Requested writes, in any order.
171
+ * @param options - Device limits and the FC06/FC16 preference.
172
+ * @returns Transactions to issue, in ascending address order.
173
+ * @throws RangeError - An address, a value, or an option is out of range.
174
+ *
175
+ * @example
176
+ * // 2000..2003 contiguous, 2010 alone
177
+ * planWrites([
178
+ * { address: 2003, value: 40 },
179
+ * { address: 2000, value: 10 },
180
+ * { address: 2010, value: 99 },
181
+ * { address: 2001, value: 20 },
182
+ * { address: 2002, value: 30 },
183
+ * ]);
184
+ * // [
185
+ * // { kind: 'multiple', address: 2000, values: Uint16Array [10, 20, 30, 40] },
186
+ * // { kind: 'single', address: 2010, value: 99 },
187
+ * // ]
188
+ */
189
+ export declare const planWrites: (writes: ReadonlyArray<RegisterWrite>, options?: PlanWritesOptions) => ReadonlyArray<WritePlanStep>;
190
+ /**
191
+ * Merges register addresses into the fewest read transactions that cover them.
192
+ *
193
+ * The planner sorts and deduplicates the addresses, then extends a span while the
194
+ * next address is within `maxGap` unrequested registers of the last one and the
195
+ * span stays within `maxRegistersPerRead`.
196
+ *
197
+ * {@link ReadPlan.locate} maps each address back to its position, so a caller
198
+ * issues the spans, keeps the responses in order, and reads each value out
199
+ * without tracking the grouping itself.
200
+ *
201
+ * @param addresses - Requested addresses, in any order, duplicates allowed.
202
+ * @param options - Device limits and the gap tolerance.
203
+ * @returns The spans to read and the index back into their responses.
204
+ * @throws RangeError - An address or an option is out of range.
205
+ *
206
+ * @example
207
+ * const plan = planReads([0x0000, 0x0001, 0x0002, 0x0020, 0x0021]);
208
+ * plan.spans; // [{ address: 0, quantity: 3 }, { address: 32, quantity: 2 }]
209
+ * plan.locate(0x0021); // { span: 1, offset: 1 }
210
+ * plan.locate(0x0010); // undefined
211
+ */
212
+ export declare const planReads: (addresses: ReadonlyArray<number>, options?: PlanReadsOptions) => ReadPlan;
@@ -33,7 +33,7 @@ export interface RetryDelayOptions {
33
33
  */
34
34
  export type RetryErrorOptions = boolean | RetryDelayOptions;
35
35
  /**
36
- * Options accepted by {@link makeRetryPolicy} and by every preset in
36
+ * Options accepted by {@link createRetryPolicy} and by every preset in
37
37
  * {@link RetryPolicies}.
38
38
  *
39
39
  * Nothing here is applied automatically — a policy only takes effect once it is
@@ -93,7 +93,7 @@ export interface ModbusRetryPolicyOptions {
93
93
  /**
94
94
  * A resolved retry policy: a schedule plus the predicates that produced it.
95
95
  *
96
- * Produced by {@link makeRetryPolicy} or a {@link RetryPolicies} preset, and
96
+ * Produced by {@link createRetryPolicy} or a {@link RetryPolicies} preset, and
97
97
  * attached to a transport or client (or piped with {@link retryModbus}). The
98
98
  * `schedule` is a plain Effect `Schedule`, so it can also be handed straight
99
99
  * to `Effect.retry`, `Effect.repeat`, or `Stream.retry`.
@@ -137,7 +137,7 @@ export declare const retryableExceptionCodes: ReadonlyArray<number>;
137
137
  *
138
138
  * @example
139
139
  * ```ts
140
- * const policy = makeRetryPolicy({
140
+ * const policy = createRetryPolicy({
141
141
  * maxRetries: 5,
142
142
  * baseDelay: "50 millis",
143
143
  * errors: { ModbusExceptionError: false },
@@ -146,7 +146,7 @@ export declare const retryableExceptionCodes: ReadonlyArray<number>;
146
146
  *
147
147
  * @see RetryPolicies — Ready-made templates built on top of this factory.
148
148
  */
149
- export declare const makeRetryPolicy: (options?: ModbusRetryPolicyOptions) => ModbusRetryPolicy;
149
+ export declare const createRetryPolicy: (options?: ModbusRetryPolicyOptions) => ModbusRetryPolicy;
150
150
  /**
151
151
  * Retry templates for common deployments.
152
152
  *
@@ -1,6 +1,9 @@
1
- import { Effect, Scope, SubscriptionRef } from 'effect';
1
+ import { Duration, Effect, Scope, SubscriptionRef } from 'effect';
2
+ import type { AsyncAsciiTransport, AsyncRtuTransport, AsyncTcpTransport } from 'modbus-rs';
3
+ import type { WasmAsciiTransport, WasmRtuTransport, WasmWsTransport } from 'modbus-rs/web';
4
+ import { type BatchingClientOptions, type BatchingModbusClient } from './batching-client';
2
5
  import { ConnectionState, type ReconnectOptions } from './connection';
3
- import { type ModbusError, ModbusNotConnectedError } from './errors';
6
+ import { ModbusNotConnectedError, type ModbusError } from './errors';
4
7
  import { type AnyModbusClient, type EffectModbusClient } from './modbus-client';
5
8
  import type { ModbusRetryPolicy } from './retry';
6
9
  /**
@@ -21,6 +24,24 @@ export interface TransportResilienceOptions {
21
24
  * enables the circuit breaker that keeps callers off a dead bus.
22
25
  */
23
26
  readonly reconnect?: ReconnectOptions;
27
+ /**
28
+ * Time limit for each operation that waits for an open or reconnect of the
29
+ * underlying transport.
30
+ *
31
+ * The `modbus-rs` bindings do not limit the connect time. A TCP connect to a
32
+ * host that does not answer waits until the operating system stops it, which
33
+ * takes about 2 minutes on Linux with default settings.
34
+ *
35
+ * The limit applies to each caller that waits for an open or reconnect.
36
+ * When it expires, that caller fails with {@link ModbusTimeoutError}.
37
+ *
38
+ * The native connect cannot be cancelled, so it continues. Later callers wait
39
+ * for the same native connect, each for at most this limit, so one transport
40
+ * has at most one pending native connect. When the native connect succeeds
41
+ * later, the transport uses it. When it fails, the next caller starts a new
42
+ * connect. Unbounded by default.
43
+ */
44
+ readonly connectTimeout?: Duration.Input;
24
45
  }
25
46
  /**
26
47
  * The `modbus-rs` transport-level retry knobs this package deliberately does
@@ -64,7 +85,7 @@ export type WithoutUpstreamRetry<TOptions> = Omit<TOptions, UpstreamRetryOptionK
64
85
  * Provides lazy connection, per-unit-ID client caching, timeout management,
65
86
  * reconnection, and graceful shutdown — all within the Effect scope.
66
87
  *
67
- * @see makeTransportScoped — Factory that produces this API from a raw transport.
88
+ * @see createTransportScoped — Factory that produces this API from a raw transport.
68
89
  */
69
90
  export interface TransportServiceApi {
70
91
  /**
@@ -75,6 +96,11 @@ export interface TransportServiceApi {
75
96
  * logic. The underlying `modbus-rs` client is cached per unit ID, so clients
76
97
  * built with different policies still share one connection.
77
98
  *
99
+ * Once `withBatchingClient` has created a client for this unit, raw FC06,
100
+ * FC16, and FC23 operations fail. Reads, coils, and other operations remain
101
+ * available. One unit must use one holding-register write path so raw writes
102
+ * cannot bypass a pending batch or its cache.
103
+ *
78
104
  * @param unitId - Modbus unit ID to address.
79
105
  * @param options - Per-client policy replacing the transport default.
80
106
  */
@@ -88,7 +114,7 @@ export interface TransportServiceApi {
88
114
  * a status indicator:
89
115
  *
90
116
  * ```ts
91
- * yield* Stream.runForEach(transport.connectionState.changes, (state) =>
117
+ * yield* Stream.runForEach(SubscriptionRef.changes(transport.connectionState), (state) =>
92
118
  * Console.log(`link: ${state._tag}`))
93
119
  * ```
94
120
  */
@@ -108,6 +134,47 @@ export interface TransportServiceApi {
108
134
  reconnect(): Effect.Effect<void, ModbusError>;
109
135
  /** Closes the transport and its scope immediately. */
110
136
  close(): Effect.Effect<void, ModbusError, Scope.Scope>;
137
+ /**
138
+ * Declares the {@link BatchingModbusClient} for a unit.
139
+ *
140
+ * Where `withClient` issues the transaction a caller names, this client
141
+ * decides the transactions for the caller: it packs neighbouring registers,
142
+ * drops writes the device already agrees with, and — when a window is
143
+ * configured — collects operations that arrive near each other.
144
+ *
145
+ * A unit is declared once, because every option here is a fact about the unit
146
+ * rather than about a caller: one unit holds one batch, one belief about the
147
+ * device, and one window. Declaring a unit twice fails with
148
+ * `ModbusInvalidArgumentError`. Use {@link batchingClient} to reach a client
149
+ * another part of the program declared.
150
+ *
151
+ * Nothing is debounced unless `debounce` asks for it, matching the rest of
152
+ * this package: default timing stays predictable.
153
+ *
154
+ * @param unitId - Modbus unit ID to address.
155
+ * @param options - The cache, the windows, and the planner limits.
156
+ */
157
+ withBatchingClient(unitId: number, options?: BatchingClientOptions & {
158
+ readonly retry?: ModbusRetryPolicy;
159
+ }): Effect.Effect<BatchingModbusClient, ModbusError>;
160
+ /**
161
+ * The {@link BatchingModbusClient} declared for a unit.
162
+ *
163
+ * Fails with `ModbusInvalidArgumentError` when nothing has declared one. A
164
+ * declaration already under way is awaited, so a lookup does not depend on
165
+ * which fiber ran first.
166
+ *
167
+ * @param unitId - Modbus unit ID to address.
168
+ */
169
+ batchingClient(unitId: number): Effect.Effect<BatchingModbusClient, ModbusError>;
170
+ /**
171
+ * Units a client has been built for on this transport.
172
+ *
173
+ * Transport-wide diagnostic state, not device ownership. For a device-specific
174
+ * shutdown action, use `BatchingModbusClient.onShutdown` on the client that
175
+ * addresses the device.
176
+ */
177
+ readonly touchedUnits: ReadonlySet<number>;
111
178
  /** Whether the transport currently has in-flight requests. */
112
179
  hasPendingRequests(): boolean;
113
180
  }
@@ -121,6 +188,8 @@ interface TransportHandle<TClient> {
121
188
  reconnect(): Promise<void>;
122
189
  pendingRequests: boolean;
123
190
  }
191
+ type TransportConstructor = typeof AsyncAsciiTransport | typeof AsyncRtuTransport | typeof AsyncTcpTransport | typeof WasmAsciiTransport | typeof WasmRtuTransport | typeof WasmWsTransport;
192
+ type TransportConstructorName = 'AsyncAsciiTransport' | 'AsyncRtuTransport' | 'AsyncTcpTransport' | 'WasmAsciiTransport' | 'WasmRtuTransport' | 'WasmWsTransport';
124
193
  /**
125
194
  * Generic factory for the scoped constructor body of an `Effect.Service`.
126
195
  *
@@ -142,7 +211,7 @@ interface TransportHandle<TClient> {
142
211
  * @param config - Optional module specifier override for browser WASM transports.
143
212
  * @returns An `Effect` that produces a {@link TransportServiceApi}.
144
213
  */
145
- export declare function makeTransportScoped<TOptions, TClient extends AnyModbusClient, TTransport extends TransportHandle<TClient>>(transportKey: string, openMethod: (TC: unknown, options: TOptions) => Promise<TTransport>, serviceName: string, config?: {
214
+ export declare function createTransportScoped<TOptions, TClient extends AnyModbusClient, TTransport extends TransportHandle<TClient>>(transportKey: TransportConstructorName, openMethod: (TC: TransportConstructor, options: TOptions) => Promise<TTransport>, serviceName: string, config?: {
146
215
  /** Which `modbus-rs` conditional export to import from. Defaults to `"modbus-rs"` (native). */
147
216
  moduleSpecifier?: 'modbus-rs' | 'modbus-rs/web';
148
217
  }): (options: TOptions & TransportResilienceOptions) => Effect.Effect<{
@@ -164,6 +233,11 @@ export declare function makeTransportScoped<TOptions, TClient extends AnyModbusC
164
233
  clearRequestTimeout: () => Effect.Effect<undefined, ModbusNotConnectedError, never>;
165
234
  reconnect: () => Effect.Effect<undefined, ModbusError, never>;
166
235
  close: () => Effect.Effect<void, ModbusError, Scope.Scope>;
236
+ withBatchingClient: (unitId: number, options?: BatchingClientOptions & {
237
+ readonly retry?: ModbusRetryPolicy;
238
+ }) => Effect.Effect<BatchingModbusClient, ModbusError>;
239
+ batchingClient: (unitId: number) => Effect.Effect<BatchingModbusClient, ModbusError>;
240
+ readonly touchedUnits: Set<number>;
167
241
  hasPendingRequests: () => boolean;
168
242
  }, never, Scope.Scope>;
169
243
  export {};
@@ -0,0 +1,34 @@
1
+ /**
2
+ * @fileoverview Caller-supplied span attributes, and the rule for merging them.
3
+ *
4
+ * @module
5
+ */
6
+ /**
7
+ * Attributes a caller attaches to the transactions it causes.
8
+ *
9
+ * The keys are opaque to this package. A caller names a register in its own
10
+ * vocabulary — an alias, a point name, a tag — and this package cannot know that
11
+ * vocabulary, so it carries the record rather than inventing a shape for it.
12
+ *
13
+ * The library's own `modbus.*` keys are applied last and win a key collision, so
14
+ * a caller cannot overwrite the record of what actually reached the bus.
15
+ */
16
+ export type ModbusSpanAttributes = Record<string, string | number | boolean>;
17
+ /**
18
+ * Joins the attributes of every caller in one batch.
19
+ *
20
+ * A batch carries several callers' worth of vocabulary, and the keys are opaque,
21
+ * so a repeated key keeps every value rather than letting one silently win. The
22
+ * values are joined with a comma, the same shape the library uses for its own
23
+ * `modbus.unit_ids`.
24
+ *
25
+ * A batch of one is unaffected: a one-element join is the value itself.
26
+ *
27
+ * @param sources - One attribute record per caller. `undefined` entries are skipped.
28
+ * @returns One record, with each key's values joined in first-seen order.
29
+ *
30
+ * @example
31
+ * mergeSpanAttributes([{ 'app.point': 'Supply' }, { 'app.point': 'Exhaust' }]);
32
+ * // { 'app.point': 'Supply,Exhaust' }
33
+ */
34
+ export declare const mergeSpanAttributes: (sources: Iterable<ModbusSpanAttributes | undefined>) => ModbusSpanAttributes;