@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/dist/index.js CHANGED
@@ -25,6 +25,9 @@ class ModbusInternalError extends Data.TaggedError("ModbusInternalError") {
25
25
 
26
26
  class ModbusNotConnectedError extends Data.TaggedError("ModbusNotConnectedError") {
27
27
  }
28
+
29
+ class ModbusCircuitOpenError extends Data.TaggedError("ModbusCircuitOpenError") {
30
+ }
28
31
  function parseExceptionCode(message) {
29
32
  const m = message.match(/\[MODBUS_EXCEPTION:(\d+)\]/);
30
33
  return m ? Number(m[1]) : undefined;
@@ -51,14 +54,204 @@ var toModbusError = (cause) => {
51
54
  return new ModbusInternalError({ cause, message });
52
55
  }
53
56
  };
57
+ // src/retry.ts
58
+ import { Duration, Effect, Schedule } from "effect";
59
+ var retryableExceptionCodes = [5, 6, 10, 11];
60
+ var defaultRetryableTags = {
61
+ ModbusTimeoutError: true,
62
+ ModbusTransportError: true,
63
+ ModbusConnectionClosedError: true,
64
+ ModbusExceptionError: true,
65
+ ModbusInvalidArgumentError: false,
66
+ ModbusNotConnectedError: false,
67
+ ModbusCircuitOpenError: true,
68
+ ModbusInternalError: false
69
+ };
70
+ var allTags = Object.keys(defaultRetryableTags);
71
+ var toMillis = (input) => Duration.toMillis(Duration.decode(input));
72
+ var resolveRetryableTags = (options) => {
73
+ const retryable = {};
74
+ for (const tag of allTags) {
75
+ const entry = options.errors?.[tag];
76
+ retryable[tag] = entry === undefined ? defaultRetryableTags[tag] : entry !== false;
77
+ }
78
+ return retryable;
79
+ };
80
+ var defaultCurve = { baseMs: 100, factor: 2, maxMs: 5000 };
81
+ var resolveCurve = (overrides, fallback) => ({
82
+ baseMs: overrides.baseDelay === undefined ? fallback.baseMs : toMillis(overrides.baseDelay),
83
+ factor: overrides.factor ?? fallback.factor,
84
+ maxMs: overrides.maxDelay === undefined ? fallback.maxMs : toMillis(overrides.maxDelay)
85
+ });
86
+ var resolveDelays = (options) => {
87
+ const policyCurve = resolveCurve(options, defaultCurve);
88
+ const delays = {};
89
+ for (const tag of allTags) {
90
+ const entry = options.errors?.[tag];
91
+ delays[tag] = resolveCurve(typeof entry === "object" ? entry : {}, policyCurve);
92
+ }
93
+ return delays;
94
+ };
95
+ var mergeOptions = (base, overrides) => overrides === undefined ? base : {
96
+ ...base,
97
+ ...overrides,
98
+ ...base.errors || overrides.errors ? { errors: { ...base.errors, ...overrides.errors } } : {}
99
+ };
100
+ var makeRetryPolicy = (options = {}) => {
101
+ const retryable = resolveRetryableTags(options);
102
+ const delays = resolveDelays(options);
103
+ const exceptions = options.retryableExceptions ?? retryableExceptionCodes;
104
+ const isRetryable = (error) => {
105
+ if (!retryable[error._tag])
106
+ return false;
107
+ if (error._tag === "ModbusExceptionError")
108
+ return exceptions.includes(error.exception);
109
+ return true;
110
+ };
111
+ const delayFor = (error, retryIndex) => {
112
+ const { baseMs, factor, maxMs } = delays[error._tag];
113
+ return Duration.millis(Math.min(maxMs, baseMs * factor ** retryIndex));
114
+ };
115
+ const base = Schedule.recurs(options.maxRetries ?? 3).pipe(Schedule.intersect(Schedule.identity()), Schedule.modifyDelay(([retryIndex, error]) => delayFor(error, retryIndex)), Schedule.whileInput(isRetryable));
116
+ const jitter = options.jitter ?? true;
117
+ const jittered = jitter === false ? base : Schedule.jitteredWith(base, jitter === true ? {} : jitter);
118
+ const schedule = options.maxElapsed === undefined ? jittered : Schedule.upTo(jittered, options.maxElapsed);
119
+ return { schedule, isRetryable };
120
+ };
121
+ var RetryPolicies = {
122
+ none: (overrides) => makeRetryPolicy(mergeOptions({ maxRetries: 0 }, overrides)),
123
+ serial: (overrides) => makeRetryPolicy(mergeOptions({
124
+ maxRetries: 3,
125
+ baseDelay: "50 millis",
126
+ factor: 2,
127
+ maxDelay: "1 second",
128
+ errors: { ModbusTimeoutError: { baseDelay: "100 millis" } }
129
+ }, overrides)),
130
+ tcp: (overrides) => makeRetryPolicy(mergeOptions({
131
+ maxRetries: 4,
132
+ baseDelay: "100 millis",
133
+ factor: 2,
134
+ maxDelay: "5 seconds",
135
+ errors: {
136
+ ModbusConnectionClosedError: { baseDelay: "250 millis", maxDelay: "10 seconds" }
137
+ }
138
+ }, overrides)),
139
+ persistent: (overrides) => makeRetryPolicy(mergeOptions({
140
+ maxRetries: 10,
141
+ baseDelay: "250 millis",
142
+ factor: 2,
143
+ maxDelay: "30 seconds",
144
+ maxElapsed: "5 minutes"
145
+ }, overrides))
146
+ };
147
+ var retryModbus = (policy) => (self) => Effect.retry(self, policy.schedule);
148
+ // src/connection.ts
149
+ import { Data as Data2, Duration as Duration2, Effect as Effect2, Either, SubscriptionRef } from "effect";
150
+ var ConnectionState = Data2.taggedEnum();
151
+ var defaultReconnectPolicy = makeRetryPolicy({
152
+ maxRetries: 5,
153
+ baseDelay: "250 millis",
154
+ factor: 2,
155
+ maxDelay: "10 seconds"
156
+ });
157
+ var defaultTriggers = [
158
+ "ModbusConnectionClosedError",
159
+ "ModbusTransportError"
160
+ ];
161
+ var resolveReconnect = (options) => {
162
+ const triggerTags = options.triggerOn ?? defaultTriggers;
163
+ return {
164
+ policy: options.policy ?? defaultReconnectPolicy,
165
+ resetAfter: Duration2.decode(options.resetAfter ?? "30 seconds"),
166
+ triggers: (error) => triggerTags.includes(error._tag)
167
+ };
168
+ };
169
+ var guardCircuit = (state) => Effect2.flatMap(SubscriptionRef.get(state), (current) => ConnectionState.$match(current, {
170
+ Disconnected: () => Effect2.void,
171
+ Connected: () => Effect2.void,
172
+ Reconnecting: ({ attempt }) => Effect2.fail(new ModbusCircuitOpenError({
173
+ cause: new Error("Transport is reconnecting"),
174
+ message: `Transport is reconnecting (attempt ${attempt}); request refused`
175
+ })),
176
+ Down: ({ cause }) => Effect2.fail(new ModbusCircuitOpenError({
177
+ cause: cause.cause,
178
+ message: `Transport is down (${cause.message}); request refused`
179
+ }))
180
+ }));
181
+ var superviseReconnect = (reconnect, state, resolved) => Effect2.gen(function* () {
182
+ while (true) {
183
+ const attempt = reconnect.pipe(Effect2.tapError(() => SubscriptionRef.update(state, (current) => ConnectionState.$is("Reconnecting")(current) ? ConnectionState.Reconnecting({ attempt: current.attempt + 1 }) : current)), retryModbus(resolved.policy));
184
+ const result = yield* Effect2.either(attempt);
185
+ if (Either.isRight(result)) {
186
+ yield* SubscriptionRef.set(state, ConnectionState.Connected());
187
+ return;
188
+ }
189
+ yield* Effect2.logDebug(`Reconnect attempts exhausted: ${result.left.message}`);
190
+ yield* SubscriptionRef.set(state, ConnectionState.Down({ cause: result.left }));
191
+ yield* Effect2.sleep(resolved.resetAfter);
192
+ const probe = yield* SubscriptionRef.modify(state, (current) => ConnectionState.$is("Down")(current) ? [true, ConnectionState.Reconnecting({ attempt: 0 })] : [false, current]);
193
+ if (!probe)
194
+ return;
195
+ }
196
+ });
54
197
  // src/AsciiTransportService.ts
55
- import { Effect as Effect4, Layer } from "effect";
198
+ import { Effect as Effect6, Layer } from "effect";
56
199
 
57
200
  // src/mocks.ts
58
- import { Effect, Schema } from "effect";
201
+ import { Effect as Effect4, Schema, SubscriptionRef as SubscriptionRef2 } from "effect";
59
202
  import {
60
203
  CoilState
61
204
  } from "modbus-rs";
205
+
206
+ // src/modbus-client.ts
207
+ import { Effect as Effect3 } from "effect";
208
+ var wrap = (try_) => Effect3.tryPromise({
209
+ try: try_,
210
+ catch: (error) => toModbusError(error)
211
+ });
212
+ var makeEffectModbusClient = (client) => ({
213
+ readHoldingRegisters: (opts) => wrap(() => client.readHoldingRegisters(opts)),
214
+ readInputRegisters: (opts) => wrap(() => client.readInputRegisters(opts)),
215
+ writeSingleRegister: (opts) => wrap(() => client.writeSingleRegister(opts)),
216
+ writeMultipleRegisters: (opts) => wrap(() => client.writeMultipleRegisters(opts)),
217
+ readWriteMultipleRegisters: (opts) => wrap(() => client.readWriteMultipleRegisters(opts)),
218
+ readCoils: (opts) => wrap(() => client.readCoils(opts)),
219
+ writeSingleCoil: (opts) => wrap(() => client.writeSingleCoil(opts)),
220
+ writeMultipleCoils: (opts) => wrap(() => client.writeMultipleCoils(opts)),
221
+ readDiscreteInputs: (opts) => wrap(() => client.readDiscreteInputs(opts)),
222
+ readFifoQueue: (opts) => wrap(() => client.readFifoQueue(opts)),
223
+ readFileRecord: (opts) => wrap(() => client.readFileRecord(opts)),
224
+ writeFileRecord: (opts) => wrap(() => client.writeFileRecord(opts)),
225
+ readExceptionStatus: () => wrap(() => client.readExceptionStatus()),
226
+ diagnostics: (opts) => wrap(() => client.diagnostics(opts)),
227
+ readDeviceIdentification: (opts) => wrap(() => client.readDeviceIdentification(opts))
228
+ });
229
+ var withResilience = (operations, resilience) => {
230
+ const run = (operation) => {
231
+ const attempt = Effect3.zipRight(resilience.guard, Effect3.suspend(operation)).pipe(Effect3.tapError((error) => resilience.report(error)));
232
+ return resilience.policy ? retryModbus(resilience.policy)(attempt) : attempt;
233
+ };
234
+ return {
235
+ readHoldingRegisters: (opts) => run(() => operations.readHoldingRegisters(opts)),
236
+ readInputRegisters: (opts) => run(() => operations.readInputRegisters(opts)),
237
+ writeSingleRegister: (opts) => run(() => operations.writeSingleRegister(opts)),
238
+ writeMultipleRegisters: (opts) => run(() => operations.writeMultipleRegisters(opts)),
239
+ readWriteMultipleRegisters: (opts) => run(() => operations.readWriteMultipleRegisters(opts)),
240
+ readCoils: (opts) => run(() => operations.readCoils(opts)),
241
+ writeSingleCoil: (opts) => run(() => operations.writeSingleCoil(opts)),
242
+ writeMultipleCoils: (opts) => run(() => operations.writeMultipleCoils(opts)),
243
+ readDiscreteInputs: (opts) => run(() => operations.readDiscreteInputs(opts)),
244
+ readFifoQueue: (opts) => run(() => operations.readFifoQueue(opts)),
245
+ readFileRecord: (opts) => run(() => operations.readFileRecord(opts)),
246
+ writeFileRecord: (opts) => run(() => operations.writeFileRecord(opts)),
247
+ readExceptionStatus: () => run(() => operations.readExceptionStatus()),
248
+ diagnostics: (opts) => run(() => operations.diagnostics(opts)),
249
+ readDeviceIdentification: (opts) => run(() => operations.readDeviceIdentification(opts)),
250
+ withRetry: (policy) => withResilience(operations, { ...resilience, policy })
251
+ };
252
+ };
253
+
254
+ // src/mocks.ts
62
255
  var CoilDefinition = Schema.Struct({
63
256
  address: Schema.Number,
64
257
  default: Schema.Boolean
@@ -112,8 +305,8 @@ var failOutOfRange = (label, address, quantity) => new ModbusInvalidArgumentErro
112
305
  message: quantity !== undefined ? `${label} read out of range: address=${address}, quantity=${quantity}` : `${label} write out of range: address=${address}`
113
306
  });
114
307
  var makeMockModbusClient = (state, unitId) => ({
115
- readCoils: Effect.fnUntraced(function* (opts) {
116
- yield* Effect.logDebug(`[Mock] unitId=${unitId} readCoils`, opts);
308
+ readCoils: Effect4.fnUntraced(function* (opts) {
309
+ yield* Effect4.logDebug(`[Mock] unitId=${unitId} readCoils`, opts);
117
310
  if (opts.address + opts.quantity > state.maxCoilAddress + 1) {
118
311
  return yield* failOutOfRange("Coil", opts.address, opts.quantity);
119
312
  }
@@ -123,8 +316,8 @@ var makeMockModbusClient = (state, unitId) => ({
123
316
  }
124
317
  return result;
125
318
  }),
126
- readDiscreteInputs: Effect.fnUntraced(function* (opts) {
127
- yield* Effect.logDebug(`[Mock] unitId=${unitId} readDiscreteInputs`, opts);
319
+ readDiscreteInputs: Effect4.fnUntraced(function* (opts) {
320
+ yield* Effect4.logDebug(`[Mock] unitId=${unitId} readDiscreteInputs`, opts);
128
321
  if (opts.address + opts.quantity > state.maxDiscreteAddress + 1) {
129
322
  return yield* failOutOfRange("DiscreteInput", opts.address, opts.quantity);
130
323
  }
@@ -134,8 +327,8 @@ var makeMockModbusClient = (state, unitId) => ({
134
327
  }
135
328
  return result;
136
329
  }),
137
- readHoldingRegisters: Effect.fnUntraced(function* (opts) {
138
- yield* Effect.logDebug(`[Mock] unitId=${unitId} readHoldingRegisters`, opts);
330
+ readHoldingRegisters: Effect4.fnUntraced(function* (opts) {
331
+ yield* Effect4.logDebug(`[Mock] unitId=${unitId} readHoldingRegisters`, opts);
139
332
  if (opts.address + opts.quantity > state.maxHoldingAddress + 1) {
140
333
  return yield* failOutOfRange("HoldingRegister", opts.address, opts.quantity);
141
334
  }
@@ -145,8 +338,8 @@ var makeMockModbusClient = (state, unitId) => ({
145
338
  }
146
339
  return new Uint16Array(result);
147
340
  }),
148
- readInputRegisters: Effect.fnUntraced(function* (opts) {
149
- yield* Effect.logDebug(`[Mock] unitId=${unitId} readInputRegisters`, opts);
341
+ readInputRegisters: Effect4.fnUntraced(function* (opts) {
342
+ yield* Effect4.logDebug(`[Mock] unitId=${unitId} readInputRegisters`, opts);
150
343
  if (opts.address + opts.quantity > state.maxInputAddress + 1) {
151
344
  return yield* failOutOfRange("InputRegister", opts.address, opts.quantity);
152
345
  }
@@ -156,15 +349,15 @@ var makeMockModbusClient = (state, unitId) => ({
156
349
  }
157
350
  return new Uint16Array(result);
158
351
  }),
159
- writeSingleCoil: Effect.fnUntraced(function* (opts) {
160
- yield* Effect.logDebug(`[Mock] unitId=${unitId} writeSingleCoil`, opts);
352
+ writeSingleCoil: Effect4.fnUntraced(function* (opts) {
353
+ yield* Effect4.logDebug(`[Mock] unitId=${unitId} writeSingleCoil`, opts);
161
354
  if (opts.address > state.maxCoilAddress) {
162
355
  return yield* failOutOfRange("Coil", opts.address);
163
356
  }
164
357
  state.coils.set(opts.address, opts.value === CoilState.On);
165
358
  }),
166
- writeMultipleCoils: Effect.fnUntraced(function* (opts) {
167
- yield* Effect.logDebug(`[Mock] unitId=${unitId} writeMultipleCoils`, opts);
359
+ writeMultipleCoils: Effect4.fnUntraced(function* (opts) {
360
+ yield* Effect4.logDebug(`[Mock] unitId=${unitId} writeMultipleCoils`, opts);
168
361
  if (opts.address + opts.values.length > state.maxCoilAddress + 1) {
169
362
  return yield* failOutOfRange("Coil", opts.address, opts.values.length);
170
363
  }
@@ -172,15 +365,15 @@ var makeMockModbusClient = (state, unitId) => ({
172
365
  state.coils.set(opts.address + i, opts.values[i] === CoilState.On);
173
366
  }
174
367
  }),
175
- writeSingleRegister: Effect.fnUntraced(function* (opts) {
176
- yield* Effect.logDebug(`[Mock] unitId=${unitId} writeSingleRegister`, opts);
368
+ writeSingleRegister: Effect4.fnUntraced(function* (opts) {
369
+ yield* Effect4.logDebug(`[Mock] unitId=${unitId} writeSingleRegister`, opts);
177
370
  if (opts.address > state.maxHoldingAddress) {
178
371
  return yield* failOutOfRange("HoldingRegister", opts.address);
179
372
  }
180
373
  state.holdingRegisters.set(opts.address, opts.value);
181
374
  }),
182
- writeMultipleRegisters: Effect.fnUntraced(function* (opts) {
183
- yield* Effect.logDebug(`[Mock] unitId=${unitId} writeMultipleRegisters`, opts);
375
+ writeMultipleRegisters: Effect4.fnUntraced(function* (opts) {
376
+ yield* Effect4.logDebug(`[Mock] unitId=${unitId} writeMultipleRegisters`, opts);
184
377
  if (opts.address + opts.values.length > state.maxHoldingAddress + 1) {
185
378
  return yield* failOutOfRange("HoldingRegister", opts.address, opts.values.length);
186
379
  }
@@ -188,8 +381,8 @@ var makeMockModbusClient = (state, unitId) => ({
188
381
  state.holdingRegisters.set(opts.address + i, opts.values[i]);
189
382
  }
190
383
  }),
191
- readWriteMultipleRegisters: Effect.fnUntraced(function* (opts) {
192
- yield* Effect.logDebug(`[Mock] unitId=${unitId} readWriteMultipleRegisters`, opts);
384
+ readWriteMultipleRegisters: Effect4.fnUntraced(function* (opts) {
385
+ yield* Effect4.logDebug(`[Mock] unitId=${unitId} readWriteMultipleRegisters`, opts);
193
386
  if (opts.writeAddress + opts.writeValues.length > state.maxHoldingAddress + 1) {
194
387
  return yield* failOutOfRange("HoldingRegister", opts.writeAddress, opts.writeValues.length);
195
388
  }
@@ -205,34 +398,34 @@ var makeMockModbusClient = (state, unitId) => ({
205
398
  }
206
399
  return new Uint16Array(result);
207
400
  }),
208
- readFifoQueue: Effect.fnUntraced(function* (_opts) {
401
+ readFifoQueue: Effect4.fnUntraced(function* (_opts) {
209
402
  return yield* new ModbusInvalidArgumentError({
210
403
  cause: new Error("FIFO queue not yet supported in mock"),
211
404
  message: "FIFO queue not yet supported in mock"
212
405
  });
213
406
  }),
214
- readFileRecord: Effect.fnUntraced(function* (_opts) {
407
+ readFileRecord: Effect4.fnUntraced(function* (_opts) {
215
408
  return yield* new ModbusInvalidArgumentError({
216
409
  cause: new Error("File records not yet supported in mock"),
217
410
  message: "File records not yet supported in mock"
218
411
  });
219
412
  }),
220
- writeFileRecord: Effect.fnUntraced(function* (_opts) {
413
+ writeFileRecord: Effect4.fnUntraced(function* (_opts) {
221
414
  return yield* new ModbusInvalidArgumentError({
222
415
  cause: new Error("File records not yet supported in mock"),
223
416
  message: "File records not yet supported in mock"
224
417
  });
225
418
  }),
226
- readExceptionStatus: Effect.fnUntraced(function* () {
227
- yield* Effect.logDebug(`[Mock] unitId=${unitId} readExceptionStatus`);
419
+ readExceptionStatus: Effect4.fnUntraced(function* () {
420
+ yield* Effect4.logDebug(`[Mock] unitId=${unitId} readExceptionStatus`);
228
421
  return 0;
229
422
  }),
230
- diagnostics: Effect.fnUntraced(function* (opts) {
231
- yield* Effect.logDebug(`[Mock] unitId=${unitId} diagnostics`, opts);
423
+ diagnostics: Effect4.fnUntraced(function* (opts) {
424
+ yield* Effect4.logDebug(`[Mock] unitId=${unitId} diagnostics`, opts);
232
425
  return { subFunction: opts.subFunction, data: new Uint16Array };
233
426
  }),
234
- readDeviceIdentification: Effect.fnUntraced(function* (opts) {
235
- yield* Effect.logDebug(`[Mock] unitId=${unitId} readDeviceIdentification`, opts);
427
+ readDeviceIdentification: Effect4.fnUntraced(function* (opts) {
428
+ yield* Effect4.logDebug(`[Mock] unitId=${unitId} readDeviceIdentification`, opts);
236
429
  return {
237
430
  conformityLevel: 1,
238
431
  moreFollows: false,
@@ -260,10 +453,32 @@ var makeMockTransport = (devices) => {
260
453
  maxInputAddress: input.maxAddress
261
454
  });
262
455
  }
263
- return (_options) => Effect.gen(function* () {
264
- yield* Effect.logDebug("Mock transport opened with devices:", deviceDefs);
456
+ return (options) => Effect4.gen(function* () {
457
+ yield* Effect4.logDebug("Mock transport opened with devices:", deviceDefs);
458
+ const connectionState = yield* SubscriptionRef2.make(ConnectionState.Connected());
459
+ const supervised = options.reconnect ? resolveReconnect(options.reconnect) : null;
460
+ const serviceScope = yield* Effect4.scope;
461
+ const reconnectOnce = Effect4.zipRight(Effect4.logDebug("Mock: reconnecting"), Effect4.suspend(() => {
462
+ const injected = options.reconnectFault?.();
463
+ return injected ? Effect4.fail(injected) : Effect4.void;
464
+ }));
465
+ const report = (error) => {
466
+ if (!supervised || !supervised.triggers(error))
467
+ return Effect4.void;
468
+ return Effect4.gen(function* () {
469
+ const claimed = yield* SubscriptionRef2.modify(connectionState, (current) => ConnectionState.$is("Connected")(current) ? [true, ConnectionState.Reconnecting({ attempt: 0 })] : [false, current]);
470
+ if (!claimed)
471
+ return;
472
+ yield* Effect4.forkIn(superviseReconnect(reconnectOnce, connectionState, supervised), serviceScope);
473
+ });
474
+ };
475
+ const guard = Effect4.zipRight(supervised ? guardCircuit(connectionState) : Effect4.void, Effect4.suspend(() => {
476
+ const injected = options.fault?.();
477
+ return injected ? Effect4.fail(injected) : Effect4.void;
478
+ }));
265
479
  return {
266
- withClient: Effect.fnUntraced(function* (unitId) {
480
+ connectionState,
481
+ withClient: Effect4.fnUntraced(function* (unitId, clientOptions) {
267
482
  const state = deviceStates.get(unitId);
268
483
  if (!state) {
269
484
  return yield* new ModbusInvalidArgumentError({
@@ -271,115 +486,122 @@ var makeMockTransport = (devices) => {
271
486
  message: `Device with unitId ${unitId} not found in mock configuration`
272
487
  });
273
488
  }
274
- return makeMockModbusClient(state, unitId);
489
+ return withResilience(makeMockModbusClient(state, unitId), {
490
+ guard,
491
+ report,
492
+ policy: clientOptions?.retry ?? options.retry
493
+ });
275
494
  }),
276
- setRequestTimeout: (_timeoutMs) => Effect.void,
277
- clearRequestTimeout: () => Effect.void,
278
- reconnect: () => Effect.asVoid(Effect.logDebug("Mock: reconnecting")),
279
- close: () => Effect.logDebug("Mock: closing transport"),
495
+ setRequestTimeout: (_timeoutMs) => Effect4.void,
496
+ clearRequestTimeout: () => Effect4.void,
497
+ reconnect: () => Effect4.zipRight(reconnectOnce, SubscriptionRef2.set(connectionState, ConnectionState.Connected())),
498
+ close: () => Effect4.zipRight(Effect4.logDebug("Mock: closing transport"), SubscriptionRef2.set(connectionState, ConnectionState.Disconnected())),
280
499
  hasPendingRequests: () => false
281
500
  };
282
501
  });
283
502
  };
284
503
 
285
504
  // src/shared-transport.ts
286
- import { Effect as Effect3, Exit, Scope } from "effect";
287
-
288
- // src/modbus-client.ts
289
- import { Effect as Effect2 } from "effect";
290
- var wrap = (try_) => Effect2.tryPromise({
291
- try: try_,
292
- catch: (error) => toModbusError(error)
293
- });
294
- var makeEffectModbusClient = (client) => ({
295
- readHoldingRegisters: (opts) => wrap(() => client.readHoldingRegisters(opts)),
296
- readInputRegisters: (opts) => wrap(() => client.readInputRegisters(opts)),
297
- writeSingleRegister: (opts) => wrap(() => client.writeSingleRegister(opts)),
298
- writeMultipleRegisters: (opts) => wrap(() => client.writeMultipleRegisters(opts)),
299
- readWriteMultipleRegisters: (opts) => wrap(() => client.readWriteMultipleRegisters(opts)),
300
- readCoils: (opts) => wrap(() => client.readCoils(opts)),
301
- writeSingleCoil: (opts) => wrap(() => client.writeSingleCoil(opts)),
302
- writeMultipleCoils: (opts) => wrap(() => client.writeMultipleCoils(opts)),
303
- readDiscreteInputs: (opts) => wrap(() => client.readDiscreteInputs(opts)),
304
- readFifoQueue: (opts) => wrap(() => client.readFifoQueue(opts)),
305
- readFileRecord: (opts) => wrap(() => client.readFileRecord(opts)),
306
- writeFileRecord: (opts) => wrap(() => client.writeFileRecord(opts)),
307
- readExceptionStatus: () => wrap(() => client.readExceptionStatus()),
308
- diagnostics: (opts) => wrap(() => client.diagnostics(opts)),
309
- readDeviceIdentification: (opts) => wrap(() => client.readDeviceIdentification(opts))
310
- });
311
-
312
- // src/shared-transport.ts
505
+ import { Deferred, Effect as Effect5, Exit, Option, Ref, Scope, SubscriptionRef as SubscriptionRef3 } from "effect";
506
+ var makeInFlight = () => Ref.make(Option.none());
507
+ var singleFlight = (inFlight, work) => Effect5.uninterruptibleMask((restore) => Effect5.gen(function* () {
508
+ const fresh = yield* Deferred.make();
509
+ const [deferred, isLeader] = yield* Ref.modify(inFlight, (current) => Option.match(current, {
510
+ onSome: (existing) => [[existing, false], current],
511
+ onNone: () => [[fresh, true], Option.some(fresh)]
512
+ }));
513
+ if (isLeader) {
514
+ yield* Effect5.forkDaemon(Effect5.interruptible(work).pipe(Effect5.onExit((exit) => Effect5.zipRight(Ref.set(inFlight, Option.none()), Deferred.done(deferred, exit)))));
515
+ }
516
+ return yield* restore(Deferred.await(deferred));
517
+ }));
313
518
  function makeTransportScoped(transportKey, openMethod, serviceName, config) {
314
- return Effect3.fnUntraced(function* (options) {
315
- const mod = config?.moduleSpecifier === "modbus-rs/web" ? yield* Effect3.promise(() => import("modbus-rs/web")) : yield* Effect3.promise(() => import("modbus-rs"));
519
+ return Effect5.fnUntraced(function* (options) {
520
+ const { retry: transportRetry, reconnect: reconnectOptions, ...rest } = options;
521
+ const openOptions = rest;
522
+ const mod = config?.moduleSpecifier === "modbus-rs/web" ? yield* Effect5.promise(() => import("modbus-rs/web")) : yield* Effect5.promise(() => import("modbus-rs"));
316
523
  const TC = mod[transportKey];
317
524
  let transport = null;
318
- let connectPromise = null;
319
- let reconnectPromise = null;
525
+ const opening = yield* makeInFlight();
526
+ const reconnecting = yield* makeInFlight();
527
+ const connectionState = yield* SubscriptionRef3.make(ConnectionState.Disconnected());
528
+ const supervised = reconnectOptions ? resolveReconnect(reconnectOptions) : null;
529
+ const serviceScope = yield* Effect5.scope;
320
530
  const clientSet = new Map;
321
531
  let closed = false;
322
- const ensureOpen = Effect3.fnUntraced(function* () {
323
- if (transport) {
324
- if (closed) {
325
- return yield* new ModbusNotConnectedError({
326
- cause: new Error("Transport has been closed"),
327
- message: "Transport has been closed"
328
- });
329
- }
330
- return transport;
331
- }
332
- if (closed) {
333
- return yield* new ModbusNotConnectedError({
334
- cause: new Error("Transport has been closed"),
335
- message: "Transport has been closed"
336
- });
337
- }
338
- if (!connectPromise) {
339
- connectPromise = openMethod(TC, options);
340
- }
341
- const t = yield* Effect3.tryPromise({
342
- try: () => connectPromise,
343
- catch: (error) => toModbusError(error)
344
- }).pipe(Effect3.catchAll((err) => {
345
- connectPromise = null;
346
- return Effect3.fail(err);
347
- }));
348
- if (closed) {
349
- connectPromise = null;
350
- yield* Effect3.fork(Effect3.promise(() => t.close()).pipe(Effect3.catchAll(() => Effect3.void)));
351
- return yield* new ModbusNotConnectedError({
352
- cause: new Error("Transport has been closed"),
353
- message: "Transport has been closed"
354
- });
355
- }
532
+ const transportClosed = () => new ModbusNotConnectedError({
533
+ cause: new Error("Transport has been closed"),
534
+ message: "Transport has been closed"
535
+ });
536
+ const closeOrphan = (t) => Effect5.forkDaemon(Effect5.ignore(Effect5.tryPromise(() => t.close())));
537
+ const openTransport = Effect5.tryPromise({
538
+ try: () => openMethod(TC, openOptions),
539
+ catch: (error) => toModbusError(error)
540
+ }).pipe(Effect5.tap((t) => closed ? closeOrphan(t) : Effect5.zipRight(Effect5.sync(() => {
356
541
  transport = t;
542
+ }), SubscriptionRef3.set(connectionState, ConnectionState.Connected()))));
543
+ const ensureOpen = Effect5.fnUntraced(function* () {
544
+ if (closed)
545
+ return yield* transportClosed();
546
+ if (transport)
547
+ return transport;
548
+ const t = yield* singleFlight(opening, openTransport);
549
+ if (closed)
550
+ return yield* transportClosed();
357
551
  return t;
358
552
  });
359
- yield* Effect3.addFinalizer(() => {
553
+ yield* Effect5.addFinalizer(() => {
360
554
  if (closed)
361
- return Effect3.void;
555
+ return Effect5.void;
362
556
  closed = true;
363
557
  const t = transport;
364
558
  if (!t)
365
- return Effect3.void;
366
- return Effect3.andThen(Effect3.logDebug(`Closing ${serviceName}`), Effect3.promise(() => t.close()));
559
+ return SubscriptionRef3.set(connectionState, ConnectionState.Disconnected());
560
+ return Effect5.andThen(Effect5.logDebug(`Closing ${serviceName}`), Effect5.zipRight(Effect5.promise(() => t.close()), SubscriptionRef3.set(connectionState, ConnectionState.Disconnected())));
367
561
  });
368
562
  const notConnectedMsg = "Transport is not connected. Call withClient() first.";
563
+ const reconnectOnce = Effect5.suspend(() => {
564
+ const t = transport;
565
+ if (closed || !t)
566
+ return transportClosed();
567
+ return singleFlight(reconnecting, Effect5.tryPromise({
568
+ try: () => t.reconnect(),
569
+ catch: (error) => toModbusError(error)
570
+ }).pipe(Effect5.tap(() => closed ? closeOrphan(t) : Effect5.void)));
571
+ });
572
+ const report = (error) => {
573
+ if (!supervised || closed || !supervised.triggers(error))
574
+ return Effect5.void;
575
+ return Effect5.gen(function* () {
576
+ const claimed = yield* SubscriptionRef3.modify(connectionState, (current) => ConnectionState.$is("Connected")(current) ? [true, ConnectionState.Reconnecting({ attempt: 0 })] : [false, current]);
577
+ if (!claimed)
578
+ return;
579
+ yield* Effect5.logDebug(`${serviceName}: reconnecting after ${error.message}`);
580
+ yield* Effect5.forkIn(superviseReconnect(reconnectOnce, connectionState, supervised), serviceScope);
581
+ });
582
+ };
583
+ const resilience = {
584
+ guard: supervised ? guardCircuit(connectionState) : Effect5.void,
585
+ report
586
+ };
369
587
  return {
370
- withClient: Effect3.fnUntraced(function* (unitId) {
588
+ connectionState,
589
+ withClient: Effect5.fnUntraced(function* (unitId, clientOptions) {
371
590
  const t = yield* ensureOpen();
372
591
  let client = clientSet.get(unitId);
373
592
  if (!client) {
374
- client = yield* Effect3.try({
593
+ client = yield* Effect5.try({
375
594
  try: () => t.createClient({ unitId }),
376
595
  catch: (error) => toModbusError(error)
377
596
  });
378
597
  clientSet.set(unitId, client);
379
598
  }
380
- return makeEffectModbusClient(client);
599
+ return withResilience(makeEffectModbusClient(client), {
600
+ ...resilience,
601
+ policy: clientOptions?.retry ?? transportRetry
602
+ });
381
603
  }),
382
- setRequestTimeout: Effect3.fnUntraced(function* (timeoutMs) {
604
+ setRequestTimeout: Effect5.fnUntraced(function* (timeoutMs) {
383
605
  const t = transport;
384
606
  if (!t || closed) {
385
607
  return yield* new ModbusNotConnectedError({
@@ -389,7 +611,7 @@ function makeTransportScoped(transportKey, openMethod, serviceName, config) {
389
611
  }
390
612
  t.setRequestTimeout(timeoutMs);
391
613
  }),
392
- clearRequestTimeout: Effect3.fnUntraced(function* () {
614
+ clearRequestTimeout: Effect5.fnUntraced(function* () {
393
615
  const t = transport;
394
616
  if (!t || closed) {
395
617
  return yield* new ModbusNotConnectedError({
@@ -399,42 +621,35 @@ function makeTransportScoped(transportKey, openMethod, serviceName, config) {
399
621
  }
400
622
  t.clearRequestTimeout();
401
623
  }),
402
- reconnect: Effect3.fnUntraced(function* () {
403
- if (closed) {
404
- return yield* new ModbusNotConnectedError({
405
- cause: new Error("Transport has been closed"),
406
- message: "Transport has been closed"
407
- });
408
- }
409
- if (transport) {
410
- if (!reconnectPromise) {
411
- reconnectPromise = transport.reconnect().then(() => {
412
- reconnectPromise = null;
413
- }).catch((err) => {
414
- reconnectPromise = null;
415
- throw err;
416
- });
417
- }
418
- yield* Effect3.tryPromise({
419
- try: () => reconnectPromise,
420
- catch: (error) => toModbusError(error)
421
- });
422
- } else {
624
+ reconnect: Effect5.fnUntraced(function* () {
625
+ if (closed)
626
+ return yield* transportClosed();
627
+ const t = transport;
628
+ if (!t) {
423
629
  yield* ensureOpen();
630
+ return;
424
631
  }
632
+ yield* singleFlight(reconnecting, Effect5.tryPromise({
633
+ try: () => t.reconnect(),
634
+ catch: (error) => toModbusError(error)
635
+ }).pipe(Effect5.tap(() => closed ? closeOrphan(t) : Effect5.void)));
636
+ if (closed)
637
+ return yield* transportClosed();
638
+ yield* SubscriptionRef3.set(connectionState, ConnectionState.Connected());
425
639
  }),
426
- close: Effect3.fnUntraced(function* () {
640
+ close: Effect5.fnUntraced(function* () {
427
641
  if (closed)
428
642
  return;
429
643
  closed = true;
644
+ yield* SubscriptionRef3.set(connectionState, ConnectionState.Disconnected());
430
645
  const t = transport;
431
646
  if (t) {
432
- yield* Effect3.tryPromise({
647
+ yield* Effect5.tryPromise({
433
648
  try: () => t.close(),
434
649
  catch: (error) => toModbusError(error)
435
650
  });
436
651
  }
437
- const scope = yield* Effect3.scope;
652
+ const scope = yield* Effect5.scope;
438
653
  yield* Scope.close(scope, Exit.void);
439
654
  }),
440
655
  hasPendingRequests: () => {
@@ -450,7 +665,7 @@ function makeTransportScoped(transportKey, openMethod, serviceName, config) {
450
665
  }
451
666
 
452
667
  // src/AsciiTransportService.ts
453
- class AsciiTransportService extends Effect4.Service()("AsciiTransportService", {
668
+ class AsciiTransportService extends Effect6.Service()("AsciiTransportService", {
454
669
  scoped: makeTransportScoped("AsyncAsciiTransport", (TC, options) => TC.open(options), "AsciiTransportService")
455
670
  }) {
456
671
  static makeMockTransport = (devices) => {
@@ -462,8 +677,8 @@ class AsciiTransportService extends Effect4.Service()("AsciiTransportService", {
462
677
  import { Context, Layer as Layer3 } from "effect";
463
678
 
464
679
  // src/RtuTransportService.ts
465
- import { Effect as Effect5, Layer as Layer2 } from "effect";
466
- class RtuTransportService extends Effect5.Service()("RtuTransportService", {
680
+ import { Effect as Effect7, Layer as Layer2 } from "effect";
681
+ class RtuTransportService extends Effect7.Service()("RtuTransportService", {
467
682
  scoped: makeTransportScoped("AsyncRtuTransport", (TC, options) => TC.open(options), "RtuTransportService")
468
683
  }) {
469
684
  static makeMockTransport = (devices) => {
@@ -486,8 +701,8 @@ class SerialTransportService extends Context.Tag("SerialTransportService")() {
486
701
  };
487
702
  }
488
703
  // src/TcpTransportService.ts
489
- import { Effect as Effect7, Layer as Layer4 } from "effect";
490
- class TcpTransportService extends Effect7.Service()("TcpTransportService", {
704
+ import { Effect as Effect8, Layer as Layer4 } from "effect";
705
+ class TcpTransportService extends Effect8.Service()("TcpTransportService", {
491
706
  scoped: makeTransportScoped("AsyncTcpTransport", (TC, options) => TC.connect(options), "TcpTransportService")
492
707
  }) {
493
708
  static makeMockTransport = (devices) => {
@@ -496,62 +711,62 @@ class TcpTransportService extends Effect7.Service()("TcpTransportService", {
496
711
  };
497
712
  }
498
713
  // src/SerialModbusServerService.ts
499
- import { Effect as Effect8, Layer as Layer5 } from "effect";
500
- var serialRtuServerLayer = (options, handlers) => Layer5.scopedDiscard(Effect8.gen(function* () {
501
- const { AsyncSerialModbusServer } = yield* Effect8.promise(() => import("modbus-rs"));
502
- const server = yield* Effect8.tryPromise({
714
+ import { Effect as Effect9, Layer as Layer5 } from "effect";
715
+ var serialRtuServerLayer = (options, handlers) => Layer5.scopedDiscard(Effect9.gen(function* () {
716
+ const { AsyncSerialModbusServer } = yield* Effect9.promise(() => import("modbus-rs"));
717
+ const server = yield* Effect9.tryPromise({
503
718
  try: () => AsyncSerialModbusServer.bindRtu(options, handlers),
504
719
  catch: (error) => toModbusError(error)
505
720
  });
506
- yield* Effect8.logDebug(`Serial RTU server bound to ${options.portPath}`);
507
- yield* Effect8.addFinalizer(() => Effect8.logDebug("Serial RTU server shutting down").pipe(Effect8.andThen(Effect8.tryPromise({
721
+ yield* Effect9.logDebug(`Serial RTU server bound to ${options.portPath}`);
722
+ yield* Effect9.addFinalizer(() => Effect9.logDebug("Serial RTU server shutting down").pipe(Effect9.andThen(Effect9.tryPromise({
508
723
  try: () => server.shutdown(),
509
724
  catch: (error) => toModbusError(error)
510
- })), Effect8.catchAll(() => Effect8.void)));
725
+ })), Effect9.catchAll(() => Effect9.void)));
511
726
  }));
512
- var serialAsciiServerLayer = (options, handlers) => Layer5.scopedDiscard(Effect8.gen(function* () {
513
- const { AsyncSerialModbusServer } = yield* Effect8.promise(() => import("modbus-rs"));
514
- const server = yield* Effect8.tryPromise({
727
+ var serialAsciiServerLayer = (options, handlers) => Layer5.scopedDiscard(Effect9.gen(function* () {
728
+ const { AsyncSerialModbusServer } = yield* Effect9.promise(() => import("modbus-rs"));
729
+ const server = yield* Effect9.tryPromise({
515
730
  try: () => AsyncSerialModbusServer.bindAscii(options, handlers),
516
731
  catch: (error) => toModbusError(error)
517
732
  });
518
- yield* Effect8.logDebug(`Serial ASCII server bound to ${options.portPath}`);
519
- yield* Effect8.addFinalizer(() => Effect8.logDebug("Serial ASCII server shutting down").pipe(Effect8.andThen(Effect8.tryPromise({
733
+ yield* Effect9.logDebug(`Serial ASCII server bound to ${options.portPath}`);
734
+ yield* Effect9.addFinalizer(() => Effect9.logDebug("Serial ASCII server shutting down").pipe(Effect9.andThen(Effect9.tryPromise({
520
735
  try: () => server.shutdown(),
521
736
  catch: (error) => toModbusError(error)
522
- })), Effect8.catchAll(() => Effect8.void)));
737
+ })), Effect9.catchAll(() => Effect9.void)));
523
738
  }));
524
739
  // src/TcpModbusServerService.ts
525
- import { Effect as Effect9, Layer as Layer6 } from "effect";
526
- var tcpServerLayer = (options, handlers) => Layer6.scopedDiscard(Effect9.gen(function* () {
527
- const { AsyncTcpModbusServer } = yield* Effect9.promise(() => import("modbus-rs"));
528
- const server = yield* Effect9.tryPromise({
740
+ import { Effect as Effect10, Layer as Layer6 } from "effect";
741
+ var tcpServerLayer = (options, handlers) => Layer6.scopedDiscard(Effect10.gen(function* () {
742
+ const { AsyncTcpModbusServer } = yield* Effect10.promise(() => import("modbus-rs"));
743
+ const server = yield* Effect10.tryPromise({
529
744
  try: () => AsyncTcpModbusServer.bind(options, handlers),
530
745
  catch: (error) => toModbusError(error)
531
746
  });
532
- yield* Effect9.logDebug(`TCP server bound to ${options.host}:${options.port}`);
533
- yield* Effect9.addFinalizer(() => Effect9.logDebug("TCP server shutting down").pipe(Effect9.andThen(Effect9.tryPromise({
747
+ yield* Effect10.logDebug(`TCP server bound to ${options.host}:${options.port}`);
748
+ yield* Effect10.addFinalizer(() => Effect10.logDebug("TCP server shutting down").pipe(Effect10.andThen(Effect10.tryPromise({
534
749
  try: () => server.shutdown(),
535
750
  catch: (error) => toModbusError(error)
536
- })), Effect9.catchAll(() => Effect9.void)));
751
+ })), Effect10.catchAll(() => Effect10.void)));
537
752
  }));
538
753
  // src/TcpGatewayService.ts
539
- import { Effect as Effect10, Layer as Layer7 } from "effect";
540
- var tcpGatewayLayer = (options, gatewayConfig) => Layer7.scopedDiscard(Effect10.gen(function* () {
541
- const { AsyncTcpGateway } = yield* Effect10.promise(() => import("modbus-rs"));
542
- const gateway = yield* Effect10.tryPromise({
754
+ import { Effect as Effect11, Layer as Layer7 } from "effect";
755
+ var tcpGatewayLayer = (options, gatewayConfig) => Layer7.scopedDiscard(Effect11.gen(function* () {
756
+ const { AsyncTcpGateway } = yield* Effect11.promise(() => import("modbus-rs"));
757
+ const gateway = yield* Effect11.tryPromise({
543
758
  try: () => AsyncTcpGateway.bind(options, gatewayConfig),
544
759
  catch: (error) => toModbusError(error)
545
760
  });
546
- yield* Effect10.logDebug(`TCP gateway bound to ${options.host}:${options.port}`);
547
- yield* Effect10.addFinalizer(() => Effect10.logDebug("TCP gateway shutting down").pipe(Effect10.andThen(Effect10.tryPromise({
761
+ yield* Effect11.logDebug(`TCP gateway bound to ${options.host}:${options.port}`);
762
+ yield* Effect11.addFinalizer(() => Effect11.logDebug("TCP gateway shutting down").pipe(Effect11.andThen(Effect11.tryPromise({
548
763
  try: () => gateway.shutdown(),
549
764
  catch: (error) => toModbusError(error)
550
- })), Effect10.catchAll(() => Effect10.void)));
765
+ })), Effect11.catchAll(() => Effect11.void)));
551
766
  }));
552
767
  // src/WasmWsTransportService.ts
553
- import { Effect as Effect11, Layer as Layer8 } from "effect";
554
- class WasmWsTransportService extends Effect11.Service()("WasmWsTransportService", {
768
+ import { Effect as Effect12, Layer as Layer8 } from "effect";
769
+ class WasmWsTransportService extends Effect12.Service()("WasmWsTransportService", {
555
770
  scoped: makeTransportScoped("WasmWsTransport", (TC, options) => TC.connect(options), "WasmWsTransportService", { moduleSpecifier: "modbus-rs/web" })
556
771
  }) {
557
772
  static makeMockTransport = (devices) => {
@@ -560,8 +775,8 @@ class WasmWsTransportService extends Effect11.Service()("WasmWsTransportService"
560
775
  };
561
776
  }
562
777
  // src/WasmRtuTransportService.ts
563
- import { Effect as Effect12, Layer as Layer9 } from "effect";
564
- class WasmRtuTransportService extends Effect12.Service()("WasmRtuTransportService", {
778
+ import { Effect as Effect13, Layer as Layer9 } from "effect";
779
+ class WasmRtuTransportService extends Effect13.Service()("WasmRtuTransportService", {
565
780
  scoped: makeTransportScoped("WasmRtuTransport", (TC, { port, ...rest }) => TC.open(port, rest), "WasmRtuTransportService", { moduleSpecifier: "modbus-rs/web" })
566
781
  }) {
567
782
  static makeMockTransport = (devices) => {
@@ -570,8 +785,8 @@ class WasmRtuTransportService extends Effect12.Service()("WasmRtuTransportServic
570
785
  };
571
786
  }
572
787
  // src/WasmAsciiTransportService.ts
573
- import { Effect as Effect13, Layer as Layer10 } from "effect";
574
- class WasmAsciiTransportService extends Effect13.Service()("WasmAsciiTransportService", {
788
+ import { Effect as Effect14, Layer as Layer10 } from "effect";
789
+ class WasmAsciiTransportService extends Effect14.Service()("WasmAsciiTransportService", {
575
790
  scoped: makeTransportScoped("WasmAsciiTransport", (TC, { port, ...rest }) => TC.open(port, rest), "WasmAsciiTransportService", { moduleSpecifier: "modbus-rs/web" })
576
791
  }) {
577
792
  static makeMockTransport = (devices) => {
@@ -663,7 +878,10 @@ export {
663
878
  tcpGatewayLayer,
664
879
  serialRtuServerLayer,
665
880
  serialAsciiServerLayer,
881
+ retryableExceptionCodes,
882
+ retryModbus,
666
883
  requestSerialPort,
884
+ makeRetryPolicy,
667
885
  WasmWsTransportService,
668
886
  WasmSerialTransportService,
669
887
  WasmRtuTransportService,
@@ -671,6 +889,7 @@ export {
671
889
  TcpTransportService,
672
890
  SerialTransportService,
673
891
  RtuTransportService,
892
+ RetryPolicies,
674
893
  ModbusTransportError,
675
894
  ModbusTimeoutError,
676
895
  ModbusNotConnectedError,
@@ -678,5 +897,7 @@ export {
678
897
  ModbusInternalError,
679
898
  ModbusExceptionError,
680
899
  ModbusConnectionClosedError,
900
+ ModbusCircuitOpenError,
901
+ ConnectionState,
681
902
  AsciiTransportService
682
903
  };