@flux-control/effect-modbus-rs 0.2.0 → 0.3.1
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 +256 -4
- package/dist/index.d.ts +54 -0
- package/dist/index.js +394 -173
- package/dist/src/AsciiTransportService.d.ts +26 -5
- package/dist/src/RtuTransportService.d.ts +26 -5
- package/dist/src/SerialTransportService.d.ts +21 -10
- package/dist/src/TcpTransportService.d.ts +26 -5
- package/dist/src/WasmAsciiTransportService.d.ts +20 -3
- package/dist/src/WasmRtuTransportService.d.ts +20 -3
- package/dist/src/WasmSerialTransportService.d.ts +19 -9
- package/dist/src/WasmWsTransportService.d.ts +18 -3
- package/dist/src/connection.d.ts +170 -0
- package/dist/src/errors.d.ts +25 -1
- package/dist/src/mock-options.test.d.ts +1 -0
- package/dist/src/mocks.d.ts +46 -7
- package/dist/src/modbus-client.d.ts +54 -3
- package/dist/src/resilience.test.d.ts +1 -0
- package/dist/src/retry.d.ts +227 -0
- package/dist/src/retry.test.d.ts +1 -0
- package/dist/src/shared-transport.d.ts +107 -6
- package/dist/src/shared-transport.test.d.ts +1 -0
- package/dist/src/upstream-options.test.d.ts +1 -0
- package/package.json +1 -1
package/dist/src/mocks.d.ts
CHANGED
|
@@ -1,8 +1,34 @@
|
|
|
1
|
-
import { Effect, Schema } from 'effect';
|
|
1
|
+
import { Effect, Schema, SubscriptionRef } from 'effect';
|
|
2
2
|
import { type AsciiTransportOptions, type RtuTransportOptions, type TcpTransportOptions } from 'modbus-rs';
|
|
3
3
|
import type { WasmWsTransportOptions, WasmSerialTransportOptions } from 'modbus-rs/web';
|
|
4
4
|
import { ModbusInvalidArgumentError, type ModbusError } from './errors';
|
|
5
|
-
import type {
|
|
5
|
+
import type { ModbusRetryPolicy } from './retry';
|
|
6
|
+
import type { TransportResilienceOptions, WithoutUpstreamRetry } from './shared-transport';
|
|
7
|
+
/**
|
|
8
|
+
* Mock-only fault injection.
|
|
9
|
+
*
|
|
10
|
+
* Lets a test or example drive retry policies, backoff, and circuit-breaker
|
|
11
|
+
* behaviour end to end without hardware: the hook runs before each operation
|
|
12
|
+
* *attempt*, so returning an error is indistinguishable from a device that
|
|
13
|
+
* refused that attempt.
|
|
14
|
+
*/
|
|
15
|
+
export interface MockFaultOptions {
|
|
16
|
+
/**
|
|
17
|
+
* Called before every operation attempt. Return an error to fail that
|
|
18
|
+
* attempt, or `undefined` to let it through.
|
|
19
|
+
*
|
|
20
|
+
* ```ts
|
|
21
|
+
* let remaining = 2;
|
|
22
|
+
* const fault = () => (remaining-- > 0 ? new ModbusTimeoutError({ ... }) : undefined);
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
readonly fault?: () => ModbusError | undefined;
|
|
26
|
+
/**
|
|
27
|
+
* Called before every mock reconnect attempt. Return an error to keep the
|
|
28
|
+
* link down, or `undefined` to let the reconnect succeed.
|
|
29
|
+
*/
|
|
30
|
+
readonly reconnectFault?: () => ModbusError | undefined;
|
|
31
|
+
}
|
|
6
32
|
/**
|
|
7
33
|
* Schema for a single coil (digital output) definition.
|
|
8
34
|
*
|
|
@@ -115,8 +141,8 @@ export type SlaveDeviceDefinitions = Schema.Schema.Type<typeof SlaveDeviceDefini
|
|
|
115
141
|
* Accepts an array of {@link SlaveDeviceDefinition} that describe the
|
|
116
142
|
* simulated Modbus slaves, their register maps, and coil states.
|
|
117
143
|
* The returned factory matches the signature expected by the transport
|
|
118
|
-
* service constructors (`
|
|
119
|
-
*
|
|
144
|
+
* service constructors (`RtuTransportOpenOptions | AsciiTransportOpenOptions |
|
|
145
|
+
* TcpTransportOpenOptions`) so it can be injected into any service layer.
|
|
120
146
|
*
|
|
121
147
|
* Unsupported function codes (FIFO queue, file records) return
|
|
122
148
|
* {@link ModbusInvalidArgumentError}.
|
|
@@ -125,11 +151,24 @@ export type SlaveDeviceDefinitions = Schema.Schema.Type<typeof SlaveDeviceDefini
|
|
|
125
151
|
* @returns A transport factory function that returns a scoped Effect
|
|
126
152
|
* providing the mock transport.
|
|
127
153
|
*/
|
|
128
|
-
export declare const makeMockTransport: (devices: SlaveDeviceDefinitions) => (
|
|
129
|
-
|
|
154
|
+
export declare const makeMockTransport: (devices: SlaveDeviceDefinitions) => (options: (WithoutUpstreamRetry<RtuTransportOptions> | WithoutUpstreamRetry<AsciiTransportOptions> | WithoutUpstreamRetry<TcpTransportOptions> | WasmWsTransportOptions | WasmSerialTransportOptions) & TransportResilienceOptions & MockFaultOptions) => Effect.Effect<{
|
|
155
|
+
connectionState: SubscriptionRef.SubscriptionRef<{
|
|
156
|
+
readonly _tag: "Connected";
|
|
157
|
+
} | {
|
|
158
|
+
readonly _tag: "Disconnected";
|
|
159
|
+
} | {
|
|
160
|
+
readonly _tag: "Down";
|
|
161
|
+
readonly cause: ModbusError;
|
|
162
|
+
} | {
|
|
163
|
+
readonly _tag: "Reconnecting";
|
|
164
|
+
readonly attempt: number;
|
|
165
|
+
}>;
|
|
166
|
+
withClient: (unitId: number, clientOptions?: {
|
|
167
|
+
readonly retry?: ModbusRetryPolicy;
|
|
168
|
+
} | undefined) => Effect.Effect<import("./modbus-client").EffectModbusClient, ModbusInvalidArgumentError, never>;
|
|
130
169
|
setRequestTimeout: (_timeoutMs: number) => Effect.Effect<void, never, never>;
|
|
131
170
|
clearRequestTimeout: () => Effect.Effect<void, never, never>;
|
|
132
171
|
reconnect: () => Effect.Effect<void, ModbusError, never>;
|
|
133
172
|
close: () => Effect.Effect<void, ModbusError, never>;
|
|
134
173
|
hasPendingRequests: () => false;
|
|
135
|
-
}, never,
|
|
174
|
+
}, never, import("effect/Scope").Scope>;
|
|
@@ -2,6 +2,7 @@ import { Effect } from 'effect';
|
|
|
2
2
|
import type { ReadRegistersOptions, WriteSingleRegisterOptions, WriteMultipleRegistersOptions, ReadWriteMultipleRegistersOptions, ReadBitsOptions, WriteSingleCoilOptions, WriteMultipleCoilsOptions, ReadFifoQueueOptions, ReadFileRecordOptions, WriteFileRecordOptions, DiagnosticsOptions, ReadDeviceIdentificationOptions, FifoQueueResponse, DiagnosticsResponse, DeviceIdentificationResponse, AsyncSerialModbusClient, AsyncTcpModbusClient, CoilState } from 'modbus-rs';
|
|
3
3
|
import type { WasmWsModbusClient, WasmSerialModbusClient } from 'modbus-rs/web';
|
|
4
4
|
import type { ModbusError } from './errors';
|
|
5
|
+
import { type ModbusRetryPolicy } from './retry';
|
|
5
6
|
/** The two native (napi) clients — same method surface, sharing one factory. */
|
|
6
7
|
export type NativeModbusClient = AsyncSerialModbusClient | AsyncTcpModbusClient;
|
|
7
8
|
/** The two browser (WASM) clients — same method surface, sharing one factory. */
|
|
@@ -9,7 +10,7 @@ export type WasmModbusClient = WasmSerialModbusClient | WasmWsModbusClient;
|
|
|
9
10
|
/** Any client this package knows how to wrap into an {@link EffectModbusClient}. */
|
|
10
11
|
export type AnyModbusClient = NativeModbusClient | WasmModbusClient;
|
|
11
12
|
/**
|
|
12
|
-
*
|
|
13
|
+
* The Modbus function-code surface, before any resilience is layered on.
|
|
13
14
|
*
|
|
14
15
|
* Each method delegates to the equivalent method on the underlying native
|
|
15
16
|
* or WASM client, converting the Promise-based API into an
|
|
@@ -23,7 +24,7 @@ export type AnyModbusClient = NativeModbusClient | WasmModbusClient;
|
|
|
23
24
|
* @see AsyncSerialModbusClient — Upstream `modbus-rs` serial client API.
|
|
24
25
|
* @see AsyncTcpModbusClient — Upstream `modbus-rs` TCP client API.
|
|
25
26
|
*/
|
|
26
|
-
export interface
|
|
27
|
+
export interface ModbusOperations {
|
|
27
28
|
/**
|
|
28
29
|
* Reads holding registers from the Modbus device (FC03).
|
|
29
30
|
*
|
|
@@ -187,4 +188,54 @@ export interface EffectModbusClient {
|
|
|
187
188
|
* @see AsyncSerialModbusClient — Upstream native serial client API.
|
|
188
189
|
* @see AsyncTcpModbusClient — Upstream native TCP client API.
|
|
189
190
|
*/
|
|
190
|
-
export declare const makeEffectModbusClient: (client: AnyModbusClient) =>
|
|
191
|
+
export declare const makeEffectModbusClient: (client: AnyModbusClient) => ModbusOperations;
|
|
192
|
+
/**
|
|
193
|
+
* Transport-owned resilience applied to every operation of a client.
|
|
194
|
+
*
|
|
195
|
+
* Assembled by the transport, not by call sites: the guard and the failure
|
|
196
|
+
* report both consult state that belongs to the transport, so a single
|
|
197
|
+
* reconnect serves every client derived from it.
|
|
198
|
+
*/
|
|
199
|
+
export interface ClientResilience {
|
|
200
|
+
/** Refuses the operation while the transport's circuit is open. */
|
|
201
|
+
readonly guard: Effect.Effect<void, ModbusError>;
|
|
202
|
+
/** Reports a failure so the transport can decide whether to reconnect. */
|
|
203
|
+
readonly report: (error: ModbusError) => Effect.Effect<void>;
|
|
204
|
+
/** Retry policy applied to each operation, if any. */
|
|
205
|
+
readonly policy?: ModbusRetryPolicy;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* A Modbus client with the transport's resilience already applied.
|
|
209
|
+
*
|
|
210
|
+
* Obtained from `transport.withClient(unitId)`. Every operation is guarded by
|
|
211
|
+
* the transport's circuit breaker, reports connection failures to the
|
|
212
|
+
* transport's reconnect supervisor, and carries whatever retry policy the
|
|
213
|
+
* transport or the `withClient` call attached.
|
|
214
|
+
*/
|
|
215
|
+
export interface EffectModbusClient extends ModbusOperations {
|
|
216
|
+
/**
|
|
217
|
+
* Returns an equivalent client whose operations use `policy` **instead of**
|
|
218
|
+
* the one this client carries.
|
|
219
|
+
*
|
|
220
|
+
* Replaces rather than composes, so an override cannot accidentally multiply
|
|
221
|
+
* attempt counts. `RetryPolicies.none()` opts a call site out entirely.
|
|
222
|
+
*
|
|
223
|
+
* ```ts
|
|
224
|
+
* yield* client.withRetry(RetryPolicies.none()).writeSingleCoil({ address: 0, value })
|
|
225
|
+
* ```
|
|
226
|
+
*/
|
|
227
|
+
withRetry(policy: ModbusRetryPolicy): EffectModbusClient;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Layers transport-owned resilience over a raw {@link ModbusOperations}.
|
|
231
|
+
*
|
|
232
|
+
* Each operation runs as: circuit guard → operation → failure report, with the
|
|
233
|
+
* retry policy (if any) wrapped around the whole sequence. Ordering matters —
|
|
234
|
+
* the guard runs per *attempt*, so once the breaker opens, a retrying operation
|
|
235
|
+
* costs nothing on the wire while it waits for the link to come back.
|
|
236
|
+
*
|
|
237
|
+
* @param operations - The unwrapped function-code surface.
|
|
238
|
+
* @param resilience - Guard, failure report, and optional retry policy.
|
|
239
|
+
* @returns A client with resilience applied to every operation.
|
|
240
|
+
*/
|
|
241
|
+
export declare const withResilience: (operations: ModbusOperations, resilience: ClientResilience) => EffectModbusClient;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { Duration, Effect, Schedule } from 'effect';
|
|
2
|
+
import type { ModbusError } from './errors';
|
|
3
|
+
/**
|
|
4
|
+
* The `_tag` of every {@link ModbusError} variant.
|
|
5
|
+
*
|
|
6
|
+
* Used to key per-error retry behaviour in
|
|
7
|
+
* {@link ModbusRetryPolicyOptions.errors}.
|
|
8
|
+
*/
|
|
9
|
+
export type ModbusErrorTag = ModbusError['_tag'];
|
|
10
|
+
/**
|
|
11
|
+
* Backoff curve for a single error category.
|
|
12
|
+
*
|
|
13
|
+
* The delay before retry _n_ (zero-based) is
|
|
14
|
+
* `min(maxDelay, baseDelay * factor ** n)`, optionally jittered by the
|
|
15
|
+
* policy-level {@link ModbusRetryPolicyOptions.jitter | jitter} setting.
|
|
16
|
+
*
|
|
17
|
+
* @see Schedule.exponential — The equivalent built-in Effect schedule.
|
|
18
|
+
*/
|
|
19
|
+
export interface RetryDelayOptions {
|
|
20
|
+
/** Delay before the first retry. */
|
|
21
|
+
readonly baseDelay?: Duration.DurationInput;
|
|
22
|
+
/** Multiplier applied to the delay after each attempt. */
|
|
23
|
+
readonly factor?: number;
|
|
24
|
+
/** Upper bound on the delay, whatever the attempt count. */
|
|
25
|
+
readonly maxDelay?: Duration.DurationInput;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Per-error retry configuration.
|
|
29
|
+
*
|
|
30
|
+
* - `false` — never retry this error.
|
|
31
|
+
* - `true` — retry using the policy-level backoff curve.
|
|
32
|
+
* - {@link RetryDelayOptions} — retry using a curve specific to this error.
|
|
33
|
+
*/
|
|
34
|
+
export type RetryErrorOptions = boolean | RetryDelayOptions;
|
|
35
|
+
/**
|
|
36
|
+
* Options accepted by {@link makeRetryPolicy} and by every preset in
|
|
37
|
+
* {@link RetryPolicies}.
|
|
38
|
+
*
|
|
39
|
+
* Nothing here is applied automatically — a policy only takes effect once it is
|
|
40
|
+
* attached to a transport, to a client, or piped over an effect with
|
|
41
|
+
* {@link retryModbus}. Library defaults stay single-shot so that timing is
|
|
42
|
+
* predictable unless retries are explicitly opted into.
|
|
43
|
+
*
|
|
44
|
+
* These are **application-level** retries, and the only ones in play: the
|
|
45
|
+
* transport-level knobs `modbus-rs` offers are withheld from every transport
|
|
46
|
+
* constructor in this package, so the two layers cannot be combined by
|
|
47
|
+
* accident. See `UpstreamRetryOptionKey` in `src/shared-transport.ts`.
|
|
48
|
+
*/
|
|
49
|
+
export interface ModbusRetryPolicyOptions {
|
|
50
|
+
/** Maximum number of retries (attempts = `maxRetries + 1`). Default `3`. */
|
|
51
|
+
readonly maxRetries?: number;
|
|
52
|
+
/** Wall-clock budget for the whole retry sequence. Unbounded by default. */
|
|
53
|
+
readonly maxElapsed?: Duration.DurationInput;
|
|
54
|
+
/**
|
|
55
|
+
* Randomness applied to each delay, as a multiplier range.
|
|
56
|
+
*
|
|
57
|
+
* `true` (default) uses Effect's `0.8 – 1.2` range; `false` disables jitter;
|
|
58
|
+
* an object customises the range. Jitter keeps a fleet of pollers from
|
|
59
|
+
* re-hitting a recovering device in lockstep.
|
|
60
|
+
*
|
|
61
|
+
* @see Schedule.jitteredWith — The underlying combinator.
|
|
62
|
+
*/
|
|
63
|
+
readonly jitter?: boolean | {
|
|
64
|
+
readonly min?: number;
|
|
65
|
+
readonly max?: number;
|
|
66
|
+
};
|
|
67
|
+
/** Policy-level delay before the first retry. Default `100 millis`. */
|
|
68
|
+
readonly baseDelay?: Duration.DurationInput;
|
|
69
|
+
/** Policy-level backoff multiplier. Default `2`. */
|
|
70
|
+
readonly factor?: number;
|
|
71
|
+
/** Policy-level delay ceiling. Default `5 seconds`. */
|
|
72
|
+
readonly maxDelay?: Duration.DurationInput;
|
|
73
|
+
/**
|
|
74
|
+
* Which {@link ModbusError} variants are retryable, with optional
|
|
75
|
+
* per-error backoff overrides.
|
|
76
|
+
*
|
|
77
|
+
* Defaults retry the errors that a healthy bus recovers from on its own —
|
|
78
|
+
* {@link ModbusTimeoutError}, {@link ModbusTransportError},
|
|
79
|
+
* {@link ModbusConnectionClosedError}, and the transient exception codes in
|
|
80
|
+
* {@link retryableExceptionCodes} — and never retry
|
|
81
|
+
* {@link ModbusInvalidArgumentError}, {@link ModbusNotConnectedError}, or
|
|
82
|
+
* {@link ModbusInternalError}.
|
|
83
|
+
*/
|
|
84
|
+
readonly errors?: Partial<Record<ModbusErrorTag, RetryErrorOptions>>;
|
|
85
|
+
/**
|
|
86
|
+
* Modbus exception codes worth retrying when the device answers with an
|
|
87
|
+
* exception response. Default {@link retryableExceptionCodes}.
|
|
88
|
+
*
|
|
89
|
+
* Only consulted when `ModbusExceptionError` is retryable at all.
|
|
90
|
+
*/
|
|
91
|
+
readonly retryableExceptions?: ReadonlyArray<number>;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* A resolved retry policy: a schedule plus the predicates that produced it.
|
|
95
|
+
*
|
|
96
|
+
* Produced by {@link makeRetryPolicy} or a {@link RetryPolicies} preset, and
|
|
97
|
+
* attached to a transport or client (or piped with {@link retryModbus}). The
|
|
98
|
+
* `schedule` is a plain Effect `Schedule`, so it can also be handed straight
|
|
99
|
+
* to `Effect.retry`, `Effect.repeat`, or `Stream.retry`.
|
|
100
|
+
*/
|
|
101
|
+
export interface ModbusRetryPolicy {
|
|
102
|
+
/**
|
|
103
|
+
* Schedule driving the retries. Its input is the failing {@link ModbusError},
|
|
104
|
+
* its output the `[retryIndex, error]` pair that produced the delay.
|
|
105
|
+
*/
|
|
106
|
+
readonly schedule: Schedule.Schedule<[number, ModbusError], ModbusError>;
|
|
107
|
+
/** Whether this policy retries the given error at all. */
|
|
108
|
+
readonly isRetryable: (error: ModbusError) => boolean;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Modbus exception codes retried by default.
|
|
112
|
+
*
|
|
113
|
+
* These are the codes that mean "ask again later" rather than "your request is
|
|
114
|
+
* wrong": `5` ACKNOWLEDGE, `6` SERVER_DEVICE_BUSY,
|
|
115
|
+
* `10` GATEWAY_PATH_UNAVAILABLE, `11` GATEWAY_TARGET_DEVICE_FAILED_TO_RESPOND.
|
|
116
|
+
*
|
|
117
|
+
* Codes such as `1` ILLEGAL_FUNCTION, `2` ILLEGAL_DATA_ADDRESS, and
|
|
118
|
+
* `3` ILLEGAL_DATA_VALUE are deterministic — retrying them only adds bus
|
|
119
|
+
* traffic and latency.
|
|
120
|
+
*/
|
|
121
|
+
export declare const retryableExceptionCodes: ReadonlyArray<number>;
|
|
122
|
+
/**
|
|
123
|
+
* Builds a {@link ModbusRetryPolicy} from {@link ModbusRetryPolicyOptions}.
|
|
124
|
+
*
|
|
125
|
+
* The resulting schedule is exponential, jittered, capped per error category,
|
|
126
|
+
* and bounded by `maxRetries` (and `maxElapsed`, when set). The retry budget is
|
|
127
|
+
* shared across error categories — only the delay curve is per-error — so an
|
|
128
|
+
* operation that fails with a mix of timeouts and transport errors still stops
|
|
129
|
+
* after `maxRetries` retries.
|
|
130
|
+
*
|
|
131
|
+
* @param options - Policy configuration. Defaults to a general-purpose policy:
|
|
132
|
+
* 3 retries, `100 millis` base, factor `2`, `5 seconds` ceiling, jittered.
|
|
133
|
+
* @returns A resolved policy for a transport, a client, or {@link retryModbus}.
|
|
134
|
+
*
|
|
135
|
+
* @example
|
|
136
|
+
* ```ts
|
|
137
|
+
* const policy = makeRetryPolicy({
|
|
138
|
+
* maxRetries: 5,
|
|
139
|
+
* baseDelay: "50 millis",
|
|
140
|
+
* errors: { ModbusExceptionError: false },
|
|
141
|
+
* });
|
|
142
|
+
* ```
|
|
143
|
+
*
|
|
144
|
+
* @see RetryPolicies — Ready-made templates built on top of this factory.
|
|
145
|
+
*/
|
|
146
|
+
export declare const makeRetryPolicy: (options?: ModbusRetryPolicyOptions) => ModbusRetryPolicy;
|
|
147
|
+
/**
|
|
148
|
+
* Retry templates for common deployments.
|
|
149
|
+
*
|
|
150
|
+
* Each entry is a factory taking optional overrides, so a template can be used
|
|
151
|
+
* as-is or as a starting point:
|
|
152
|
+
*
|
|
153
|
+
* ```ts
|
|
154
|
+
* RetryPolicies.serial();
|
|
155
|
+
* RetryPolicies.serial({ maxRetries: 6 });
|
|
156
|
+
* ```
|
|
157
|
+
*
|
|
158
|
+
* None of these are applied implicitly — pick one and pipe through
|
|
159
|
+
* {@link retryModbus}.
|
|
160
|
+
*/
|
|
161
|
+
export declare const RetryPolicies: {
|
|
162
|
+
/**
|
|
163
|
+
* No retries — the library default behaviour, stated explicitly.
|
|
164
|
+
*
|
|
165
|
+
* Useful as a base for opting individual call sites out of an
|
|
166
|
+
* application-wide policy.
|
|
167
|
+
*/
|
|
168
|
+
readonly none: (overrides?: ModbusRetryPolicyOptions) => ModbusRetryPolicy;
|
|
169
|
+
/**
|
|
170
|
+
* Serial (RTU/ASCII) buses: short delays, tight ceiling.
|
|
171
|
+
*
|
|
172
|
+
* A framing or CRC error on RS-485 is usually a collision or a noise burst,
|
|
173
|
+
* so retrying quickly is the right move; timeouts back off a little further
|
|
174
|
+
* to let a slow device finish its turnaround. Reconnects are reserved for a
|
|
175
|
+
* genuinely closed port — reopening a USB serial adapter is expensive.
|
|
176
|
+
*/
|
|
177
|
+
readonly serial: (overrides?: ModbusRetryPolicyOptions) => ModbusRetryPolicy;
|
|
178
|
+
/**
|
|
179
|
+
* Modbus/TCP: room for a TCP handshake to re-establish.
|
|
180
|
+
*
|
|
181
|
+
* A transport error over TCP generally means the socket is gone rather than
|
|
182
|
+
* a corrupted frame, so it reconnects alongside an explicit connection close.
|
|
183
|
+
*/
|
|
184
|
+
readonly tcp: (overrides?: ModbusRetryPolicyOptions) => ModbusRetryPolicy;
|
|
185
|
+
/**
|
|
186
|
+
* Long-running background polling: keep trying, but stop hammering.
|
|
187
|
+
*
|
|
188
|
+
* Backs off to a 30-second ceiling and gives up after 5 minutes of failures
|
|
189
|
+
* so a permanently dead device surfaces as an error instead of silently
|
|
190
|
+
* retrying forever.
|
|
191
|
+
*/
|
|
192
|
+
readonly persistent: (overrides?: ModbusRetryPolicyOptions) => ModbusRetryPolicy;
|
|
193
|
+
};
|
|
194
|
+
/**
|
|
195
|
+
* Applies a {@link ModbusRetryPolicy} to an effect.
|
|
196
|
+
*
|
|
197
|
+
* Errors the policy considers non-retryable fail through immediately, so an
|
|
198
|
+
* `ModbusInvalidArgumentError` still surfaces on the first attempt under a
|
|
199
|
+
* policy tuned for flaky wiring.
|
|
200
|
+
*
|
|
201
|
+
* **This wraps rather than replaces.** `withClient(unitId, { retry })` and
|
|
202
|
+
* `client.withRetry(policy)` are resolved inside the client, so they discard
|
|
203
|
+
* the policy already in force. `retryModbus` is piped *around* an effect the
|
|
204
|
+
* client has already wrapped in its own retry, and nothing in that path can see
|
|
205
|
+
* the inner policy — so over a policied client both run and the attempt counts
|
|
206
|
+
* multiply (a `maxRetries: 2` client under a `maxRetries: 3` pipe makes 3 × 4 =
|
|
207
|
+
* 12 attempts). Reach for it only over a {@link RetryPolicies.none} client, to
|
|
208
|
+
* drive a compound operation as a unit; for a single operation, prefer
|
|
209
|
+
* `client.withRetry(policy)`.
|
|
210
|
+
*
|
|
211
|
+
* @param policy - The policy to apply.
|
|
212
|
+
* @returns A combinator that can be piped over any effect failing with
|
|
213
|
+
* {@link ModbusError}.
|
|
214
|
+
*
|
|
215
|
+
* @example
|
|
216
|
+
* ```ts
|
|
217
|
+
* // A read-modify-write retried as a unit — retrying either frame alone
|
|
218
|
+
* // would be wrong. The client carries no policy of its own.
|
|
219
|
+
* const client = yield* transport.withClient(1, { retry: RetryPolicies.none() });
|
|
220
|
+
*
|
|
221
|
+
* yield* Effect.gen(function* () {
|
|
222
|
+
* const current = yield* client.readHoldingRegisters({ address: 0, quantity: 2 });
|
|
223
|
+
* yield* client.writeMultipleRegisters({ address: 0, values: bump(current) });
|
|
224
|
+
* }).pipe(retryModbus(RetryPolicies.serial()));
|
|
225
|
+
* ```
|
|
226
|
+
*/
|
|
227
|
+
export declare const retryModbus: (policy: ModbusRetryPolicy) => <A, E extends ModbusError, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -1,6 +1,63 @@
|
|
|
1
|
-
import { Effect, Scope } from 'effect';
|
|
1
|
+
import { Effect, Scope, SubscriptionRef } from 'effect';
|
|
2
|
+
import { ConnectionState, type ReconnectOptions } from './connection';
|
|
2
3
|
import { type ModbusError, ModbusNotConnectedError } from './errors';
|
|
3
4
|
import { type AnyModbusClient, type EffectModbusClient } from './modbus-client';
|
|
5
|
+
import type { ModbusRetryPolicy } from './retry';
|
|
6
|
+
/**
|
|
7
|
+
* Resilience configuration accepted by every transport service alongside its
|
|
8
|
+
* `modbus-rs` options.
|
|
9
|
+
*
|
|
10
|
+
* Both are opt-in: with neither set, a transport behaves exactly as it always
|
|
11
|
+
* has — one attempt per operation, reconnection only when asked for.
|
|
12
|
+
*/
|
|
13
|
+
export interface TransportResilienceOptions {
|
|
14
|
+
/**
|
|
15
|
+
* Retry policy applied to every operation of every client from this
|
|
16
|
+
* transport. Overridable per client and per operation.
|
|
17
|
+
*/
|
|
18
|
+
readonly retry?: ModbusRetryPolicy;
|
|
19
|
+
/**
|
|
20
|
+
* Hands reconnection to a supervisor fiber owned by this transport, and
|
|
21
|
+
* enables the circuit breaker that keeps callers off a dead bus.
|
|
22
|
+
*/
|
|
23
|
+
readonly reconnect?: ReconnectOptions;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The `modbus-rs` transport-level retry knobs this package deliberately does
|
|
27
|
+
* not expose.
|
|
28
|
+
*
|
|
29
|
+
* They are withheld rather than merely discouraged because enabling them is
|
|
30
|
+
* never the right call under this design:
|
|
31
|
+
*
|
|
32
|
+
* - **They retry below the Effect boundary.** A failure they paper over never
|
|
33
|
+
* reaches the policy, the circuit breaker, or the logs — the caller sees one
|
|
34
|
+
* slow success instead of several failures and a recovery, and any
|
|
35
|
+
* caller-side timeout is measuring inflated time.
|
|
36
|
+
* - **They reconnect.** Upstream re-establishes the link inline and replays
|
|
37
|
+
* in-flight requests after it, which races the one supervisor fiber that is
|
|
38
|
+
* supposed to own reconnection for the whole transport.
|
|
39
|
+
* - **They multiply.** Neither layer knows about the other, so attempt counts
|
|
40
|
+
* compound and the two backoff curves interleave.
|
|
41
|
+
* - **`retryDelayMs` is flat and unjittered**, the collision pattern that
|
|
42
|
+
* `RetryPolicies.serial()` exists to break up; `retryBackoffStrategy` is
|
|
43
|
+
* documented upstream as inert, so setting it does nothing at all.
|
|
44
|
+
*
|
|
45
|
+
* Use the `retry` and `reconnect` options on {@link TransportResilienceOptions}
|
|
46
|
+
* instead. Callers who genuinely need frame-level resends can construct a raw
|
|
47
|
+
* `modbus-rs` client directly, where the trade-off is explicit.
|
|
48
|
+
*/
|
|
49
|
+
export type UpstreamRetryOptionKey = 'retryAttempts' | 'retryDelayMs' | 'retryBackoffStrategy';
|
|
50
|
+
/**
|
|
51
|
+
* Upstream `modbus-rs` transport options with the retry knobs removed.
|
|
52
|
+
*
|
|
53
|
+
* Applied to every transport-creation entry point this package exposes. All
|
|
54
|
+
* three keys are optional upstream, so the result stays assignable to the
|
|
55
|
+
* original type and still satisfies the upstream `open`/`connect` call.
|
|
56
|
+
*
|
|
57
|
+
* @typeParam TOptions - The upstream options type (e.g. `RtuTransportOptions`).
|
|
58
|
+
* @see UpstreamRetryOptionKey — Why these are withheld.
|
|
59
|
+
*/
|
|
60
|
+
export type WithoutUpstreamRetry<TOptions> = Omit<TOptions, UpstreamRetryOptionKey>;
|
|
4
61
|
/**
|
|
5
62
|
* Shared API surface that every transport service exposes to consumers.
|
|
6
63
|
*
|
|
@@ -10,13 +67,44 @@ import { type AnyModbusClient, type EffectModbusClient } from './modbus-client';
|
|
|
10
67
|
* @see makeTransportScoped — Factory that produces this API from a raw transport.
|
|
11
68
|
*/
|
|
12
69
|
export interface TransportServiceApi {
|
|
13
|
-
/**
|
|
14
|
-
|
|
70
|
+
/**
|
|
71
|
+
* Obtains an {@link EffectModbusClient} for the given unit ID.
|
|
72
|
+
*
|
|
73
|
+
* The client carries the transport's retry policy unless `options.retry`
|
|
74
|
+
* replaces it — useful when one bus hosts device types that need different
|
|
75
|
+
* logic. The underlying `modbus-rs` client is cached per unit ID, so clients
|
|
76
|
+
* built with different policies still share one connection.
|
|
77
|
+
*
|
|
78
|
+
* @param unitId - Modbus unit ID to address.
|
|
79
|
+
* @param options - Per-client policy replacing the transport default.
|
|
80
|
+
*/
|
|
81
|
+
withClient(unitId: number, options?: {
|
|
82
|
+
readonly retry?: ModbusRetryPolicy;
|
|
83
|
+
}): Effect.Effect<EffectModbusClient, ModbusError>;
|
|
84
|
+
/**
|
|
85
|
+
* Live connection state, published by the transport.
|
|
86
|
+
*
|
|
87
|
+
* Read it with `SubscriptionRef.get`, or subscribe with `.changes` to drive
|
|
88
|
+
* a status indicator:
|
|
89
|
+
*
|
|
90
|
+
* ```ts
|
|
91
|
+
* yield* Stream.runForEach(transport.connectionState.changes, (state) =>
|
|
92
|
+
* Console.log(`link: ${state._tag}`))
|
|
93
|
+
* ```
|
|
94
|
+
*/
|
|
95
|
+
readonly connectionState: SubscriptionRef.SubscriptionRef<ConnectionState>;
|
|
15
96
|
/** Sets a request timeout (ms) on the underlying transport. Fails if not connected. */
|
|
16
97
|
setRequestTimeout(timeoutMs: number): Effect.Effect<void, ModbusError>;
|
|
17
98
|
/** Clears the request timeout. Fails if not connected. */
|
|
18
99
|
clearRequestTimeout(): Effect.Effect<void, ModbusError>;
|
|
19
|
-
/**
|
|
100
|
+
/**
|
|
101
|
+
* Reconnects the transport. Opens lazily if no prior connection exists.
|
|
102
|
+
*
|
|
103
|
+
* Concurrent calls are coalesced: fibers arriving while a reconnect is in
|
|
104
|
+
* flight join it rather than starting another, and all of them observe its
|
|
105
|
+
* result. Fails with `ModbusNotConnectedError` if the transport was closed
|
|
106
|
+
* while the reconnect was running.
|
|
107
|
+
*/
|
|
20
108
|
reconnect(): Effect.Effect<void, ModbusError>;
|
|
21
109
|
/** Closes the transport and its scope immediately. */
|
|
22
110
|
close(): Effect.Effect<void, ModbusError, Scope.Scope>;
|
|
@@ -57,8 +145,21 @@ interface TransportHandle<TClient> {
|
|
|
57
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?: {
|
|
58
146
|
/** Which `modbus-rs` conditional export to import from. Defaults to `"modbus-rs"` (native). */
|
|
59
147
|
moduleSpecifier?: 'modbus-rs' | 'modbus-rs/web';
|
|
60
|
-
}): (options: TOptions) => Effect.Effect<{
|
|
61
|
-
|
|
148
|
+
}): (options: TOptions & TransportResilienceOptions) => Effect.Effect<{
|
|
149
|
+
connectionState: SubscriptionRef.SubscriptionRef<{
|
|
150
|
+
readonly _tag: "Connected";
|
|
151
|
+
} | {
|
|
152
|
+
readonly _tag: "Disconnected";
|
|
153
|
+
} | {
|
|
154
|
+
readonly _tag: "Down";
|
|
155
|
+
readonly cause: ModbusError;
|
|
156
|
+
} | {
|
|
157
|
+
readonly _tag: "Reconnecting";
|
|
158
|
+
readonly attempt: number;
|
|
159
|
+
}>;
|
|
160
|
+
withClient: (unitId: number, clientOptions?: {
|
|
161
|
+
readonly retry?: ModbusRetryPolicy;
|
|
162
|
+
} | undefined) => Effect.Effect<EffectModbusClient, ModbusError, never>;
|
|
62
163
|
setRequestTimeout: (timeoutMs: number) => Effect.Effect<undefined, ModbusNotConnectedError, never>;
|
|
63
164
|
clearRequestTimeout: () => Effect.Effect<undefined, ModbusNotConnectedError, never>;
|
|
64
165
|
reconnect: () => Effect.Effect<undefined, ModbusError, never>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|