@flux-control/effect-modbus-rs 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -394,7 +394,23 @@ const damper =
394
394
  const same = yield * transport.batchingClient(3); // elsewhere, no options to restate
395
395
  ```
396
396
 
397
- `withBatchingClient` declares. Declaring a unit twice fails with `ModbusInvalidArgumentError`, whatever the second call asks for. `batchingClient` looks up, and fails the same way when nothing has declared that unit. A lookup that arrives while a declaration is still in flight waits for it, so neither call has to know which one ran first.
397
+ `withBatchingClient` declares. Declaring a unit twice fails with `ModbusUnitAlreadyDeclaredError`, whatever the second call asks for. `batchingClient` looks up, and fails with `ModbusInvalidArgumentError` when nothing has declared that unit. A lookup that arrives while a declaration is still in flight waits for it, so neither call has to know which one ran first.
398
+
399
+ A declaration belongs to the transport scope, not to the caller. It stays when the caller is interrupted, and when the owner that made it closes while the transport stays open. An owner that can start again over the same transport can catch the error and recover the existing client:
400
+
401
+ ```ts
402
+ const damper =
403
+ yield *
404
+ transport
405
+ .withBatchingClient(3, { debounce })
406
+ .pipe(
407
+ Effect.catchTag('ModbusUnitAlreadyDeclaredError', (error) =>
408
+ transport.batchingClient(error.unitId),
409
+ ),
410
+ );
411
+ ```
412
+
413
+ The recovered client keeps the options of the first declaration. `client.debounce` gives its debounce windows as frozen, resolved `Duration` values, so the owner can compare them with the windows that it expects. An omitted `maxHold` shows as four times the write window, which is the limit that the client applies.
398
414
 
399
415
  ### Shutdown
400
416
 
@@ -547,6 +563,8 @@ Errors from the underlying Rust layer are mapped to typed `Effect` errors via `D
547
563
 
548
564
  Handle with `Effect.catchTags`. The `ModbusError` union type covers all seven variants.
549
565
 
566
+ `withBatchingClient` can also fail with `ModbusUnitAlreadyDeclaredError` when the unit already has a batching client. This error is not a member of `ModbusError`, because it never comes from the bus. See [Declaring a unit, and reaching for it](#declaring-a-unit-and-reaching-for-it).
567
+
550
568
  ## Resilience: retries, reconnection, and circuit breaking
551
569
 
552
570
  > **These are application-level policies, and they are opt-in.**
@@ -711,6 +729,32 @@ Effect.gen(function* () {
711
729
  });
712
730
  ```
713
731
 
732
+ ### Connect timeout
733
+
734
+ The `modbus-rs` bindings do not limit the time of a connect. A TCP connect to a host that does not answer waits until the operating system stops it. On Linux with default settings, this takes about 2 minutes. During that time, every operation joins the same pending connect.
735
+
736
+ Set `connectTimeout` to limit how long each operation waits for an open or a reconnect:
737
+
738
+ ```ts
739
+ TcpTransportService.make({
740
+ host: '192.0.2.1',
741
+ port: 502,
742
+ connectTimeout: '3 seconds', // limit for each wait on an open or reconnect
743
+ reconnect: {},
744
+ });
745
+ ```
746
+
747
+ The limit applies to each operation that waits for an open or reconnect. When it expires, that operation fails with `ModbusTimeoutError`.
748
+
749
+ The native connect cannot be cancelled, so it continues after the timeout:
750
+
751
+ - Later operations wait for the same native connect, each for at most the limit. A transport never has more than one pending native connect.
752
+ - When the native connect succeeds later, the transport uses it. A late reconnect that succeeds after the supervisor published `Down` publishes `Connected` at once.
753
+ - When the native connect fails, the next operation starts a new connect.
754
+ - A supervised reconnect attempt that times out is retried by the reconnect policy by default. When the attempts are exhausted, the transport publishes `Down`.
755
+
756
+ The option applies to every transport. It is unbounded by default. `requestTimeoutMs` limits each request, but it does not limit the connect.
757
+
714
758
  ### Retrying a transaction
715
759
 
716
760
  `retryModbus(policy)` remains exported for the one case the transport cannot express: driving a **compound** operation as a unit, where retrying individual frames would be wrong.
package/dist/index.d.ts CHANGED
@@ -159,7 +159,7 @@ export type { DebouncedWrite, WriteDebouncer, WriteDebouncerOptions } from './sr
159
159
  export { createReadDebouncer } from './src/read-debouncer';
160
160
  export type { ReadDebouncer, ReadDebouncerOptions } from './src/read-debouncer';
161
161
  export { makeBatchingClient, createBatchingRegistry } from './src/batching-client';
162
- export type { BatchingClientOptions, BatchingDebounceOptions, BatchingModbusClient, BatchingRegistry, BatchingRegistryDeps, BatchingRegisterReader, } from './src/batching-client';
162
+ export type { BatchingClientOptions, BatchingDebounceOptions, BatchingDebounceWindows, BatchingModbusClient, BatchingRegistry, BatchingRegistryDeps, BatchingRegisterReader, } from './src/batching-client';
163
163
  export { mergeSpanAttributes } from './src/span-attributes';
164
164
  export type { ModbusSpanAttributes } from './src/span-attributes';
165
165
  export { AsciiTransportService } from './src/AsciiTransportService';
package/dist/index.js CHANGED
@@ -25,6 +25,9 @@ class ModbusNotConnectedError extends Data.TaggedError("ModbusNotConnectedError"
25
25
 
26
26
  class ModbusCircuitOpenError extends Data.TaggedError("ModbusCircuitOpenError") {
27
27
  }
28
+
29
+ class ModbusUnitAlreadyDeclaredError extends Data.TaggedError("ModbusUnitAlreadyDeclaredError") {
30
+ }
28
31
  function parseExceptionCode(message) {
29
32
  const m = message.match(/\[MODBUS_EXCEPTION:(\d+)\]/);
30
33
  return m ? Number(m[1]) : undefined;
@@ -393,9 +396,13 @@ var createRegisterCache = () => {
393
396
  };
394
397
  // src/write-debouncer.ts
395
398
  import { Clock, Deferred, Duration as Duration3, Effect as Effect3, Fiber, Semaphore } from "effect";
399
+ var resolveWriteHold = (window, maxHold) => {
400
+ const windowMs = Duration3.toMillis(Duration3.fromInputUnsafe(window));
401
+ const maxHoldMs = maxHold === undefined ? windowMs * 4 : Duration3.toMillis(Duration3.fromInputUnsafe(maxHold));
402
+ return { windowMs, maxHoldMs };
403
+ };
396
404
  var createWriteDebouncer = (options) => Effect3.gen(function* () {
397
- const windowMs = Duration3.toMillis(Duration3.fromInputUnsafe(options.window));
398
- const maxHoldMs = options.maxHold === undefined ? windowMs * 4 : Duration3.toMillis(Duration3.fromInputUnsafe(options.maxHold));
405
+ const { windowMs, maxHoldMs } = resolveWriteHold(options.window, options.maxHold);
399
406
  const scope = yield* Effect3.scope;
400
407
  const lock = yield* Semaphore.make(1);
401
408
  const flushLock = yield* Semaphore.make(1);
@@ -663,6 +670,7 @@ var createReadDebouncer = (options) => Effect4.gen(function* () {
663
670
  // src/batching-client.ts
664
671
  import {
665
672
  Deferred as Deferred3,
673
+ Duration as Duration5,
666
674
  Effect as Effect5,
667
675
  Exit as Exit2,
668
676
  Fiber as Fiber3,
@@ -693,6 +701,22 @@ var mergeSpanAttributes = (sources) => {
693
701
  };
694
702
 
695
703
  // src/batching-client.ts
704
+ var resolveDebounceWindows = (options) => {
705
+ if (options === undefined)
706
+ return;
707
+ const hold = options.writes === undefined ? undefined : resolveWriteHold(options.writes.window, options.writes.maxHold);
708
+ return Object.freeze({
709
+ ...hold && {
710
+ writes: Object.freeze({
711
+ window: Duration5.millis(hold.windowMs),
712
+ maxHold: Duration5.millis(hold.maxHoldMs)
713
+ })
714
+ },
715
+ ...options.reads && {
716
+ reads: Object.freeze({ window: Duration5.fromInputUnsafe(options.reads.window) })
717
+ }
718
+ });
719
+ };
696
720
  var makeBatchingClient = (options) => Effect5.gen(function* () {
697
721
  const { unitId, client, cache } = options;
698
722
  const scope = yield* Effect5.scope;
@@ -772,6 +796,7 @@ var makeBatchingClient = (options) => Effect5.gen(function* () {
772
796
  return {
773
797
  unitId,
774
798
  cache,
799
+ debounce: resolveDebounceWindows(options.debounce),
775
800
  write: writes.write,
776
801
  writeNow: writes.writeNow,
777
802
  writeAll: writes.writeAll,
@@ -842,7 +867,7 @@ var createBatchingRegistry = (deps) => {
842
867
  };
843
868
  const alreadyDeclaredError = (unitId) => {
844
869
  const message = `Unit ${unitId} already has a batching client. One unit has one batch, so a unit ` + `is declared once. Use batchingClient(${unitId}) to reach the existing one.`;
845
- return new ModbusInvalidArgumentError({ cause: new Error(message), message });
870
+ return new ModbusUnitAlreadyDeclaredError({ cause: new Error(message), message, unitId });
846
871
  };
847
872
  const notDeclaredError = (unitId) => {
848
873
  const message = `No batching client is declared for unit ${unitId}. Declare one with ` + `withBatchingClient(${unitId}) before reaching for it.`;
@@ -1250,7 +1275,7 @@ var createMockTransport = (devices) => {
1250
1275
  };
1251
1276
 
1252
1277
  // src/shared-transport.ts
1253
- import { Deferred as Deferred4, Effect as Effect8, Exit as Exit4, Option, Ref, Scope as Scope3, SubscriptionRef as SubscriptionRef4 } from "effect";
1278
+ import { Deferred as Deferred4, Duration as Duration6, Effect as Effect8, Exit as Exit4, Option, Ref, Scope as Scope3, SubscriptionRef as SubscriptionRef4 } from "effect";
1254
1279
  var makeInFlight = () => Ref.make(Option.none());
1255
1280
  var singleFlight = (inFlight, work) => Effect8.uninterruptibleMask((restore) => Effect8.gen(function* () {
1256
1281
  const fresh = yield* Deferred4.make();
@@ -1292,9 +1317,11 @@ var loadTransportConstructor = async (transportKey, moduleSpecifier) => {
1292
1317
  function createTransportScoped(transportKey, openMethod, serviceName, config) {
1293
1318
  return Effect8.fnUntraced(function* (options) {
1294
1319
  const { retry: transportRetry, reconnect: reconnectOptions } = options;
1320
+ const connectTimeout = options.connectTimeout === undefined ? undefined : Duration6.fromInputUnsafe(options.connectTimeout);
1295
1321
  const openOptions = { ...options };
1296
1322
  Reflect.deleteProperty(openOptions, "retry");
1297
1323
  Reflect.deleteProperty(openOptions, "reconnect");
1324
+ Reflect.deleteProperty(openOptions, "connectTimeout");
1298
1325
  const TC = yield* Effect8.promise(() => loadTransportConstructor(transportKey, config?.moduleSpecifier ?? "modbus-rs"));
1299
1326
  let transport = null;
1300
1327
  const opening = yield* makeInFlight();
@@ -1310,6 +1337,15 @@ function createTransportScoped(transportKey, openMethod, serviceName, config) {
1310
1337
  message: "Transport has been closed"
1311
1338
  });
1312
1339
  const closeOrphan = (t) => Effect8.forkDetach(Effect8.ignore(Effect8.tryPromise(() => t.close())));
1340
+ const limitConnect = (wait) => {
1341
+ if (connectTimeout === undefined)
1342
+ return wait;
1343
+ const message = `${serviceName}: connect did not complete within ${Duration6.format(connectTimeout)}`;
1344
+ return Effect8.timeoutOrElse(wait, {
1345
+ duration: connectTimeout,
1346
+ orElse: () => Effect8.fail(new ModbusTimeoutError({ cause: new Error(message), message }))
1347
+ });
1348
+ };
1313
1349
  const openTransport = Effect8.tryPromise({
1314
1350
  try: () => openMethod(TC, openOptions),
1315
1351
  catch: (cause) => toModbusError(cause instanceof Error ? cause : new Error(String(cause)))
@@ -1321,7 +1357,7 @@ function createTransportScoped(transportKey, openMethod, serviceName, config) {
1321
1357
  return yield* transportClosed();
1322
1358
  if (transport)
1323
1359
  return transport;
1324
- const t = yield* singleFlight(opening, openTransport);
1360
+ const t = yield* limitConnect(singleFlight(opening, openTransport));
1325
1361
  if (closed)
1326
1362
  return yield* transportClosed();
1327
1363
  return t;
@@ -1340,14 +1376,15 @@ function createTransportScoped(transportKey, openMethod, serviceName, config) {
1340
1376
  });
1341
1377
  yield* Effect8.addFinalizer(() => Effect8.orDie(closeTransport));
1342
1378
  const notConnectedMsg = "Transport is not connected. Call withClient() first.";
1379
+ const reconnectTransport = (t) => limitConnect(singleFlight(reconnecting, Effect8.tryPromise({
1380
+ try: () => t.reconnect(),
1381
+ catch: (cause) => toModbusError(cause instanceof Error ? cause : new Error(String(cause)))
1382
+ }).pipe(Effect8.tap(() => closed ? closeOrphan(t) : SubscriptionRef4.update(connectionState, (current) => ConnectionState.$is("Down")(current) ? ConnectionState.Connected() : current)))));
1343
1383
  const reconnectOnce = Effect8.suspend(() => {
1344
1384
  const t = transport;
1345
1385
  if (closed || !t)
1346
1386
  return transportClosed();
1347
- return singleFlight(reconnecting, Effect8.tryPromise({
1348
- try: () => t.reconnect(),
1349
- catch: (cause) => toModbusError(cause instanceof Error ? cause : new Error(String(cause)))
1350
- }).pipe(Effect8.tap(() => closed ? closeOrphan(t) : Effect8.void)));
1387
+ return reconnectTransport(t);
1351
1388
  });
1352
1389
  const report = (error) => {
1353
1390
  if (closed || !resolvedReconnect.triggers(error))
@@ -1424,10 +1461,7 @@ function createTransportScoped(transportKey, openMethod, serviceName, config) {
1424
1461
  yield* ensureOpen();
1425
1462
  return;
1426
1463
  }
1427
- yield* singleFlight(reconnecting, Effect8.tryPromise({
1428
- try: () => t.reconnect(),
1429
- catch: (cause) => toModbusError(cause instanceof Error ? cause : new Error(String(cause)))
1430
- }).pipe(Effect8.tap(() => closed ? closeOrphan(t) : Effect8.void)));
1464
+ yield* reconnectTransport(t);
1431
1465
  if (closed)
1432
1466
  return yield* transportClosed();
1433
1467
  yield* SubscriptionRef4.set(connectionState, ConnectionState.Connected());
@@ -1685,6 +1719,7 @@ export {
1685
1719
  ModbusNotConnectedError,
1686
1720
  ModbusTimeoutError,
1687
1721
  ModbusTransportError,
1722
+ ModbusUnitAlreadyDeclaredError,
1688
1723
  RetryPolicies,
1689
1724
  RtuTransportService,
1690
1725
  SerialTransportService,
@@ -51,7 +51,7 @@ export declare class AsciiTransportService extends AsciiTransportService_base {
51
51
  close: () => import("effect/Effect").Effect<void, import("./errors").ModbusError, import("effect/Scope").Scope>;
52
52
  withBatchingClient: (unitId: number, options?: import("./batching-client").BatchingClientOptions & {
53
53
  readonly retry?: import("./retry").ModbusRetryPolicy;
54
- }) => import("effect/Effect").Effect<import("./batching-client").BatchingModbusClient, import("./errors").ModbusError>;
54
+ }) => import("effect/Effect").Effect<import("./batching-client").BatchingModbusClient, import("./errors").ModbusError | import("./errors").ModbusUnitAlreadyDeclaredError>;
55
55
  batchingClient: (unitId: number) => import("effect/Effect").Effect<import("./batching-client").BatchingModbusClient, import("./errors").ModbusError>;
56
56
  readonly touchedUnits: Set<number>;
57
57
  hasPendingRequests: () => boolean;
@@ -51,7 +51,7 @@ export declare class RtuTransportService extends RtuTransportService_base {
51
51
  close: () => import("effect/Effect").Effect<void, import("./errors").ModbusError, import("effect/Scope").Scope>;
52
52
  withBatchingClient: (unitId: number, options?: import("./batching-client").BatchingClientOptions & {
53
53
  readonly retry?: import("./retry").ModbusRetryPolicy;
54
- }) => import("effect/Effect").Effect<import("./batching-client").BatchingModbusClient, import("./errors").ModbusError>;
54
+ }) => import("effect/Effect").Effect<import("./batching-client").BatchingModbusClient, import("./errors").ModbusError | import("./errors").ModbusUnitAlreadyDeclaredError>;
55
55
  batchingClient: (unitId: number) => import("effect/Effect").Effect<import("./batching-client").BatchingModbusClient, import("./errors").ModbusError>;
56
56
  readonly touchedUnits: Set<number>;
57
57
  hasPendingRequests: () => boolean;
@@ -51,7 +51,7 @@ export declare class TcpTransportService extends TcpTransportService_base {
51
51
  close: () => import("effect/Effect").Effect<void, import("./errors").ModbusError, import("effect/Scope").Scope>;
52
52
  withBatchingClient: (unitId: number, options?: import("./batching-client").BatchingClientOptions & {
53
53
  readonly retry?: import("./retry").ModbusRetryPolicy;
54
- }) => import("effect/Effect").Effect<import("./batching-client").BatchingModbusClient, import("./errors").ModbusError>;
54
+ }) => import("effect/Effect").Effect<import("./batching-client").BatchingModbusClient, import("./errors").ModbusError | import("./errors").ModbusUnitAlreadyDeclaredError>;
55
55
  batchingClient: (unitId: number) => import("effect/Effect").Effect<import("./batching-client").BatchingModbusClient, import("./errors").ModbusError>;
56
56
  readonly touchedUnits: Set<number>;
57
57
  hasPendingRequests: () => boolean;
@@ -57,7 +57,7 @@ export declare class WasmAsciiTransportService extends WasmAsciiTransportService
57
57
  close: () => import("effect/Effect").Effect<void, import("./errors").ModbusError, import("effect/Scope").Scope>;
58
58
  withBatchingClient: (unitId: number, options?: import("./batching-client").BatchingClientOptions & {
59
59
  readonly retry?: import("./retry").ModbusRetryPolicy;
60
- }) => import("effect/Effect").Effect<import("./batching-client").BatchingModbusClient, import("./errors").ModbusError>;
60
+ }) => import("effect/Effect").Effect<import("./batching-client").BatchingModbusClient, import("./errors").ModbusError | import("./errors").ModbusUnitAlreadyDeclaredError>;
61
61
  batchingClient: (unitId: number) => import("effect/Effect").Effect<import("./batching-client").BatchingModbusClient, import("./errors").ModbusError>;
62
62
  readonly touchedUnits: Set<number>;
63
63
  hasPendingRequests: () => boolean;
@@ -57,7 +57,7 @@ export declare class WasmRtuTransportService extends WasmRtuTransportService_bas
57
57
  close: () => import("effect/Effect").Effect<void, import("./errors").ModbusError, import("effect/Scope").Scope>;
58
58
  withBatchingClient: (unitId: number, options?: import("./batching-client").BatchingClientOptions & {
59
59
  readonly retry?: import("./retry").ModbusRetryPolicy;
60
- }) => import("effect/Effect").Effect<import("./batching-client").BatchingModbusClient, import("./errors").ModbusError>;
60
+ }) => import("effect/Effect").Effect<import("./batching-client").BatchingModbusClient, import("./errors").ModbusError | import("./errors").ModbusUnitAlreadyDeclaredError>;
61
61
  batchingClient: (unitId: number) => import("effect/Effect").Effect<import("./batching-client").BatchingModbusClient, import("./errors").ModbusError>;
62
62
  readonly touchedUnits: Set<number>;
63
63
  hasPendingRequests: () => boolean;
@@ -44,7 +44,7 @@ export declare class WasmWsTransportService extends WasmWsTransportService_base
44
44
  close: () => import("effect/Effect").Effect<void, import("./errors").ModbusError, import("effect/Scope").Scope>;
45
45
  withBatchingClient: (unitId: number, options?: import("./batching-client").BatchingClientOptions & {
46
46
  readonly retry?: import("./retry").ModbusRetryPolicy;
47
- }) => import("effect/Effect").Effect<import("./batching-client").BatchingModbusClient, import("./errors").ModbusError>;
47
+ }) => import("effect/Effect").Effect<import("./batching-client").BatchingModbusClient, import("./errors").ModbusError | import("./errors").ModbusUnitAlreadyDeclaredError>;
48
48
  batchingClient: (unitId: number) => import("effect/Effect").Effect<import("./batching-client").BatchingModbusClient, import("./errors").ModbusError>;
49
49
  readonly touchedUnits: Set<number>;
50
50
  hasPendingRequests: () => boolean;
@@ -12,7 +12,7 @@
12
12
  */
13
13
  import { Duration, Effect, Scope, SubscriptionRef } from 'effect';
14
14
  import { ConnectionState } from './connection';
15
- import { type ModbusError } from './errors';
15
+ import { ModbusUnitAlreadyDeclaredError, type ModbusError } from './errors';
16
16
  import type { EffectModbusClient, ModbusOperations } from './modbus-client';
17
17
  import { type RegisterCache } from './register-cache';
18
18
  import { type PlanReadsOptions, type PlanWritesOptions, type RegisterWrite } from './register-plan';
@@ -44,6 +44,23 @@ export interface BatchingDebounceOptions {
44
44
  readonly window: Duration.Input;
45
45
  };
46
46
  }
47
+ /**
48
+ * The debounce windows that a batching client applies, as resolved values.
49
+ *
50
+ * A write entry always has `maxHold`. When the declaration omitted it, it is
51
+ * four times the window, which is the limit the write debouncer applies.
52
+ */
53
+ export interface BatchingDebounceWindows {
54
+ /** The write window and the hold limit. Omitted when writes are not debounced. */
55
+ readonly writes?: {
56
+ readonly window: Duration.Duration;
57
+ readonly maxHold: Duration.Duration;
58
+ };
59
+ /** The read window. Omitted when reads are not debounced. */
60
+ readonly reads?: {
61
+ readonly window: Duration.Duration;
62
+ };
63
+ }
47
64
  /** Options for {@link makeBatchingClient}. */
48
65
  export interface BatchingClientOptions {
49
66
  /**
@@ -111,6 +128,17 @@ export interface BatchingModbusClient extends BatchingRegisterReader {
111
128
  readonly flush: Effect.Effect<void>;
112
129
  /** The cache this client filters against, or `undefined` when `cache` is `false`. */
113
130
  readonly cache: RegisterCache | undefined;
131
+ /**
132
+ * The debounce windows this client applies, or `undefined` when its
133
+ * declaration had no `debounce` option.
134
+ *
135
+ * The values are resolved and frozen when the client is built. An omitted
136
+ * `maxHold` shows as four times the write window.
137
+ *
138
+ * A caller that recovers an existing client with `batchingClient` did not
139
+ * choose these windows. Compare them with the windows that the caller expects.
140
+ */
141
+ readonly debounce: BatchingDebounceWindows | undefined;
114
142
  /**
115
143
  * Runs an action when the calling scope closes, while the bus is still open
116
144
  * and before this client is torn down.
@@ -161,7 +189,10 @@ export interface BatchingRegistryDeps {
161
189
  /** The batching half of a transport's API. */
162
190
  export interface BatchingRegistry {
163
191
  /**
164
- * Declares the batching client for a unit. Fails if the unit already has one.
192
+ * Declares the batching client for a unit.
193
+ *
194
+ * Fails with `ModbusUnitAlreadyDeclaredError` if the unit already has a
195
+ * completed declaration or a declaration in progress.
165
196
  *
166
197
  * The declaration belongs to the registry's transport scope, not to the
167
198
  * calling fiber. Construction therefore continues if the caller is
@@ -171,7 +202,7 @@ export interface BatchingRegistry {
171
202
  */
172
203
  withBatchingClient(unitId: number, options?: BatchingClientOptions & {
173
204
  readonly retry?: ModbusRetryPolicy;
174
- }): Effect.Effect<BatchingModbusClient, ModbusError>;
205
+ }): Effect.Effect<BatchingModbusClient, ModbusError | ModbusUnitAlreadyDeclaredError>;
175
206
  /** The batching client for a unit. Fails if nothing has declared one. */
176
207
  batchingClient(unitId: number): Effect.Effect<BatchingModbusClient, ModbusError>;
177
208
  /** Disables raw holding-register writes once a batching client exists for the unit. */
@@ -30,6 +30,9 @@ declare const ModbusTimeoutError_base: new <A extends Record<string, any> = {}>(
30
30
  * with {@link RtuTransportOptions.requestTimeoutMs | requestTimeoutMs} /
31
31
  * {@link RtuTransportOptions.responseTimeoutMs | responseTimeoutMs}.
32
32
  *
33
+ * The transport also raises this error locally when an operation waits for an
34
+ * open or reconnect longer than the `connectTimeout` transport option.
35
+ *
33
36
  * @see ModbusErrorCode.TIMEOUT — `modbus-rs` error code that triggers this error.
34
37
  */
35
38
  export declare class ModbusTimeoutError extends ModbusTimeoutError_base<{
@@ -159,6 +162,42 @@ export declare class ModbusCircuitOpenError extends ModbusCircuitOpenError_base<
159
162
  readonly message: string;
160
163
  }> {
161
164
  }
165
+ declare const ModbusUnitAlreadyDeclaredError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
166
+ readonly _tag: "ModbusUnitAlreadyDeclaredError";
167
+ } & Readonly<A>;
168
+ /**
169
+ * Error indicating that a unit already has a batching client on this transport.
170
+ *
171
+ * This is a **local** error. `modbus-rs` never returns it. The transport
172
+ * raises it from `withBatchingClient` when the unit has a completed
173
+ * declaration or a declaration in progress.
174
+ *
175
+ * The declaration belongs to the transport scope, not to the caller. It stays
176
+ * after its caller is interrupted, and after the owner that made it closes.
177
+ * Catch this error to recover the existing client with `batchingClient(unitId)`.
178
+ *
179
+ * This error is not a member of {@link ModbusError}. It can occur only when a
180
+ * caller declares a unit. It never occurs on the bus, so retry policies do not
181
+ * apply to it.
182
+ *
183
+ * @example
184
+ * ```ts
185
+ * const client = yield* transport.withBatchingClient(3).pipe(
186
+ * Effect.catchTag('ModbusUnitAlreadyDeclaredError', (error) =>
187
+ * transport.batchingClient(error.unitId),
188
+ * ),
189
+ * );
190
+ * ```
191
+ */
192
+ export declare class ModbusUnitAlreadyDeclaredError extends ModbusUnitAlreadyDeclaredError_base<{
193
+ /** A descriptive error. */
194
+ readonly cause: Error;
195
+ /** Human-readable explanation of the error. */
196
+ readonly message: string;
197
+ /** The unit that already has a batching client. */
198
+ readonly unitId: number;
199
+ }> {
200
+ }
162
201
  /**
163
202
  * Union of all typed Modbus errors emitted by this library.
164
203
  *
@@ -153,7 +153,7 @@ export declare const createMockTransport: (devices: SlaveDeviceDefinitions) => (
153
153
  } | undefined) => Effect.Effect<import("./modbus-client").EffectModbusClient, ModbusInvalidArgumentError | ModbusNotConnectedError, never>;
154
154
  withBatchingClient: (unitId: number, clientOptions?: BatchingClientOptions & {
155
155
  readonly retry?: ModbusRetryPolicy;
156
- }) => Effect.Effect<import("./batching-client").BatchingModbusClient, ModbusError, never>;
156
+ }) => Effect.Effect<import("./batching-client").BatchingModbusClient, import("./errors").ModbusUnitAlreadyDeclaredError | ModbusError, never>;
157
157
  batchingClient: (unitId: number) => Effect.Effect<import("./batching-client").BatchingModbusClient, ModbusError, never>;
158
158
  readonly touchedUnits: Set<number>;
159
159
  setRequestTimeout: (_timeoutMs: number) => Effect.Effect<void, ModbusNotConnectedError, never>;
@@ -1,9 +1,9 @@
1
- import { Effect, Scope, SubscriptionRef } from 'effect';
1
+ import { Duration, Effect, Scope, SubscriptionRef } from 'effect';
2
2
  import type { AsyncAsciiTransport, AsyncRtuTransport, AsyncTcpTransport } from 'modbus-rs';
3
3
  import type { WasmAsciiTransport, WasmRtuTransport, WasmWsTransport } from 'modbus-rs/web';
4
4
  import { type BatchingClientOptions, type BatchingModbusClient } from './batching-client';
5
5
  import { ConnectionState, type ReconnectOptions } from './connection';
6
- import { ModbusNotConnectedError, type ModbusError } from './errors';
6
+ import { ModbusNotConnectedError, type ModbusError, type ModbusUnitAlreadyDeclaredError } from './errors';
7
7
  import { type AnyModbusClient, type EffectModbusClient } from './modbus-client';
8
8
  import type { ModbusRetryPolicy } from './retry';
9
9
  /**
@@ -24,6 +24,24 @@ export interface TransportResilienceOptions {
24
24
  * enables the circuit breaker that keeps callers off a dead bus.
25
25
  */
26
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;
27
45
  }
28
46
  /**
29
47
  * The `modbus-rs` transport-level retry knobs this package deliberately does
@@ -127,8 +145,12 @@ export interface TransportServiceApi {
127
145
  * A unit is declared once, because every option here is a fact about the unit
128
146
  * rather than about a caller: one unit holds one batch, one belief about the
129
147
  * device, and one window. Declaring a unit twice fails with
130
- * `ModbusInvalidArgumentError`. Use {@link batchingClient} to reach a client
131
- * another part of the program declared.
148
+ * `ModbusUnitAlreadyDeclaredError`. Use {@link batchingClient} to reach a
149
+ * client another part of the program declared.
150
+ *
151
+ * The declaration lasts as long as the transport. A new owner that starts over
152
+ * a transport that stays open finds the unit declared. It can catch
153
+ * `ModbusUnitAlreadyDeclaredError` and use {@link batchingClient} instead.
132
154
  *
133
155
  * Nothing is debounced unless `debounce` asks for it, matching the rest of
134
156
  * this package: default timing stays predictable.
@@ -138,7 +160,7 @@ export interface TransportServiceApi {
138
160
  */
139
161
  withBatchingClient(unitId: number, options?: BatchingClientOptions & {
140
162
  readonly retry?: ModbusRetryPolicy;
141
- }): Effect.Effect<BatchingModbusClient, ModbusError>;
163
+ }): Effect.Effect<BatchingModbusClient, ModbusError | ModbusUnitAlreadyDeclaredError>;
142
164
  /**
143
165
  * The {@link BatchingModbusClient} declared for a unit.
144
166
  *
@@ -217,7 +239,7 @@ export declare function createTransportScoped<TOptions, TClient extends AnyModbu
217
239
  close: () => Effect.Effect<void, ModbusError, Scope.Scope>;
218
240
  withBatchingClient: (unitId: number, options?: BatchingClientOptions & {
219
241
  readonly retry?: ModbusRetryPolicy;
220
- }) => Effect.Effect<BatchingModbusClient, ModbusError>;
242
+ }) => Effect.Effect<BatchingModbusClient, ModbusError | ModbusUnitAlreadyDeclaredError>;
221
243
  batchingClient: (unitId: number) => Effect.Effect<BatchingModbusClient, ModbusError>;
222
244
  readonly touchedUnits: Set<number>;
223
245
  hasPendingRequests: () => boolean;
@@ -131,4 +131,17 @@ export interface WriteDebouncer {
131
131
  * debouncer.write({ address: 2001, value: 20 }),
132
132
  * ], { concurrency: 'unbounded' });
133
133
  */
134
+ /**
135
+ * Resolves the window and the hold limit that a write debouncer applies.
136
+ *
137
+ * The batching client reports these values, so both use this one rule.
138
+ *
139
+ * @param window - The write window.
140
+ * @param maxHold - The hold limit. When omitted, it is four times the window.
141
+ * @returns The window and the hold limit in milliseconds.
142
+ */
143
+ export declare const resolveWriteHold: (window: Duration.Input, maxHold: Duration.Input | undefined) => {
144
+ windowMs: number;
145
+ maxHoldMs: number;
146
+ };
134
147
  export declare const createWriteDebouncer: (options: WriteDebouncerOptions) => Effect.Effect<WriteDebouncer, never, Scope.Scope>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flux-control/effect-modbus-rs",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Type-safe Modbus communication via Effect-TS, wrapping the modbus-rs npm bindings.",
5
5
  "license": "GPL-3.0",
6
6
  "repository": {
@@ -47,10 +47,10 @@
47
47
  "@effect/platform-bun": "^4.0.0-rc.109",
48
48
  "@oxlint/plugins": "1.76.0",
49
49
  "@types/bun": "latest",
50
- "effect": "^4.0.0-rc.109",
50
+ "effect": "4.0.0-rc.109",
51
51
  "fallow": "^2.104.0",
52
52
  "husky": "^9.1.7",
53
- "oxfmt": "^0.60.0",
53
+ "oxfmt": "^0.71.0",
54
54
  "oxlint": "1.76.0",
55
55
  "typescript": "^7.0.2"
56
56
  },