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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +317 -20
  2. package/dist/index.d.ts +62 -1
  3. package/dist/index.js +1034 -213
  4. package/dist/src/AsciiTransportService.d.ts +14 -10
  5. package/dist/src/RtuTransportService.d.ts +14 -10
  6. package/dist/src/TcpTransportService.d.ts +14 -10
  7. package/dist/src/WasmAsciiTransportService.d.ts +15 -11
  8. package/dist/src/WasmRtuTransportService.d.ts +15 -11
  9. package/dist/src/WasmWsTransportService.d.ts +14 -10
  10. package/dist/src/batching-client.d.ts +197 -0
  11. package/dist/src/connection.d.ts +6 -3
  12. package/dist/src/mocks.d.ts +14 -8
  13. package/dist/src/modbus-client.d.ts +3 -1
  14. package/dist/src/read-debouncer.d.ts +100 -0
  15. package/dist/src/register-cache.d.ts +90 -0
  16. package/dist/src/register-plan.d.ts +212 -0
  17. package/dist/src/retry.d.ts +4 -4
  18. package/dist/src/shared-transport.d.ts +60 -4
  19. package/dist/src/span-attributes.d.ts +34 -0
  20. package/dist/src/write-debouncer.d.ts +134 -0
  21. package/dist/test/retry.test.d.ts +1 -0
  22. package/dist/test/shared-transport.test.d.ts +1 -0
  23. package/dist/test/span-attributes.test.d.ts +1 -0
  24. package/dist/test/upstream-options.test.d.ts +1 -0
  25. package/dist/test/wasm-mocks.test.d.ts +1 -0
  26. package/dist/test/write-debouncer.test.d.ts +1 -0
  27. package/package.json +5 -4
  28. /package/dist/{src/mock-options.test.d.ts → test/batching-client.test.d.ts} +0 -0
  29. /package/dist/{src/mocks.test.d.ts → test/mock-options.test.d.ts} +0 -0
  30. /package/dist/{src/modbus-client.wasm.test.d.ts → test/mocks.test.d.ts} +0 -0
  31. /package/dist/{src/resilience.test.d.ts → test/modbus-client.wasm.test.d.ts} +0 -0
  32. /package/dist/{src/retry.test.d.ts → test/read-debouncer.test.d.ts} +0 -0
  33. /package/dist/{src/shared-transport.test.d.ts → test/register-cache.test.d.ts} +0 -0
  34. /package/dist/{src/upstream-options.test.d.ts → test/register-plan.test.d.ts} +0 -0
  35. /package/dist/{src/wasm-mocks.test.d.ts → test/resilience.test.d.ts} +0 -0
package/dist/index.js CHANGED
@@ -1,6 +1,3 @@
1
- import { createRequire } from "node:module";
2
- var __require = /* @__PURE__ */ createRequire(import.meta.url);
3
-
4
1
  // src/errors.ts
5
2
  import { Data } from "effect";
6
3
  import { getModbusErrorCode, ModbusErrorCode } from "modbus-rs";
@@ -67,10 +64,19 @@ var defaultRetryableTags = {
67
64
  ModbusCircuitOpenError: true,
68
65
  ModbusInternalError: false
69
66
  };
70
- var allTags = Object.keys(defaultRetryableTags);
67
+ var allTags = [
68
+ "ModbusTimeoutError",
69
+ "ModbusTransportError",
70
+ "ModbusConnectionClosedError",
71
+ "ModbusExceptionError",
72
+ "ModbusInvalidArgumentError",
73
+ "ModbusNotConnectedError",
74
+ "ModbusCircuitOpenError",
75
+ "ModbusInternalError"
76
+ ];
71
77
  var toMillis = (input) => Duration.toMillis(Duration.fromInputUnsafe(input));
72
78
  var resolveRetryableTags = (options) => {
73
- const retryable = {};
79
+ const retryable = { ...defaultRetryableTags };
74
80
  for (const tag of allTags) {
75
81
  const entry = options.errors?.[tag];
76
82
  retryable[tag] = entry === undefined ? defaultRetryableTags[tag] : entry !== false;
@@ -85,19 +91,25 @@ var resolveCurve = (overrides, fallback) => ({
85
91
  });
86
92
  var resolveDelays = (options) => {
87
93
  const policyCurve = resolveCurve(options, defaultCurve);
88
- const delays = {};
94
+ const delays = {
95
+ ModbusTimeoutError: policyCurve,
96
+ ModbusTransportError: policyCurve,
97
+ ModbusConnectionClosedError: policyCurve,
98
+ ModbusExceptionError: policyCurve,
99
+ ModbusInvalidArgumentError: policyCurve,
100
+ ModbusNotConnectedError: policyCurve,
101
+ ModbusCircuitOpenError: policyCurve,
102
+ ModbusInternalError: policyCurve
103
+ };
89
104
  for (const tag of allTags) {
90
105
  const entry = options.errors?.[tag];
91
- delays[tag] = resolveCurve(typeof entry === "object" ? entry : {}, policyCurve);
106
+ const overrides = entry === undefined || entry === true || entry === false ? {} : entry;
107
+ delays[tag] = resolveCurve(overrides, policyCurve);
92
108
  }
93
109
  return delays;
94
110
  };
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 = {}) => {
111
+ var mergeOptions = (base, overrides) => overrides === undefined ? base : base.errors || overrides.errors ? { ...base, ...overrides, errors: { ...base.errors, ...overrides.errors } } : { ...base, ...overrides };
112
+ var createRetryPolicy = (options = {}) => {
101
113
  const retryable = resolveRetryableTags(options);
102
114
  const delays = resolveDelays(options);
103
115
  const exceptions = options.retryableExceptions ?? retryableExceptionCodes;
@@ -123,15 +135,15 @@ var makeRetryPolicy = (options = {}) => {
123
135
  return { schedule, isRetryable };
124
136
  };
125
137
  var RetryPolicies = {
126
- none: (overrides) => makeRetryPolicy(mergeOptions({ maxRetries: 0 }, overrides)),
127
- serial: (overrides) => makeRetryPolicy(mergeOptions({
138
+ none: (overrides) => createRetryPolicy(mergeOptions({ maxRetries: 0 }, overrides)),
139
+ serial: (overrides) => createRetryPolicy(mergeOptions({
128
140
  maxRetries: 3,
129
141
  baseDelay: "50 millis",
130
142
  factor: 2,
131
143
  maxDelay: "1 second",
132
144
  errors: { ModbusTimeoutError: { baseDelay: "100 millis" } }
133
145
  }, overrides)),
134
- tcp: (overrides) => makeRetryPolicy(mergeOptions({
146
+ tcp: (overrides) => createRetryPolicy(mergeOptions({
135
147
  maxRetries: 4,
136
148
  baseDelay: "100 millis",
137
149
  factor: 2,
@@ -140,7 +152,7 @@ var RetryPolicies = {
140
152
  ModbusConnectionClosedError: { baseDelay: "250 millis", maxDelay: "10 seconds" }
141
153
  }
142
154
  }, overrides)),
143
- persistent: (overrides) => makeRetryPolicy(mergeOptions({
155
+ persistent: (overrides) => createRetryPolicy(mergeOptions({
144
156
  maxRetries: 10,
145
157
  baseDelay: "250 millis",
146
158
  factor: 2,
@@ -152,7 +164,7 @@ var retryModbus = (policy) => (self) => Effect.retry(self, policy.schedule);
152
164
  // src/connection.ts
153
165
  import { Data as Data2, Duration as Duration2, Effect as Effect2, Result, SubscriptionRef } from "effect";
154
166
  var ConnectionState = Data2.taggedEnum();
155
- var defaultReconnectPolicy = makeRetryPolicy({
167
+ var defaultReconnectPolicy = createRetryPolicy({
156
168
  maxRetries: 5,
157
169
  baseDelay: "250 millis",
158
170
  factor: 2,
@@ -206,22 +218,708 @@ var claimReconnect = (reconnect, state, resolved, scope, onClaim) => Effect2.gen
206
218
  yield* onClaim;
207
219
  yield* Effect2.forkIn(superviseReconnect(reconnect, state, resolved), scope);
208
220
  });
221
+ // src/register-plan.ts
222
+ var MODBUS_MAX_WRITE_REGISTERS = 123;
223
+ var MODBUS_MAX_READ_REGISTERS = 125;
224
+ var MAX_REGISTER_VALUE = 65535;
225
+ var MAX_REGISTER_ADDRESS = 65535;
226
+ var MIN_REGISTER_VALUE = -32768;
227
+ var assertPositiveInteger = (name, value) => {
228
+ if (!Number.isInteger(value) || value < 1) {
229
+ throw new RangeError(`${name} must be an integer of 1 or more, got ${value}`);
230
+ }
231
+ };
232
+ var assertTransactionLimit = (name, value, maximum) => {
233
+ if (!Number.isInteger(value) || value < 1 || value > maximum) {
234
+ throw new RangeError(`${name} must be an integer from 1 to ${maximum}, got ${value}`);
235
+ }
236
+ };
237
+ var assertAddress = (address) => {
238
+ if (!Number.isInteger(address) || address < 0 || address > MAX_REGISTER_ADDRESS) {
239
+ throw new RangeError(`address must be an integer from 0 to ${MAX_REGISTER_ADDRESS}, got ${address}`);
240
+ }
241
+ };
242
+ var encodeRegisterValue = (value) => {
243
+ if (!Number.isInteger(value) || value < MIN_REGISTER_VALUE || value > MAX_REGISTER_VALUE) {
244
+ throw new RangeError(`value must be an integer from ${MIN_REGISTER_VALUE} to ${MAX_REGISTER_VALUE}, got ${value}`);
245
+ }
246
+ return value & MAX_REGISTER_VALUE;
247
+ };
248
+ var planWrites = (writes, options = {}) => {
249
+ const maxRegistersPerWrite = options.maxRegistersPerWrite ?? MODBUS_MAX_WRITE_REGISTERS;
250
+ const minRunLength = options.minRunLength ?? 2;
251
+ assertTransactionLimit("maxRegistersPerWrite", maxRegistersPerWrite, MODBUS_MAX_WRITE_REGISTERS);
252
+ assertPositiveInteger("minRunLength", minRunLength);
253
+ const latest = new Map;
254
+ for (const write of writes) {
255
+ assertAddress(write.address);
256
+ latest.set(write.address, encodeRegisterValue(write.value));
257
+ }
258
+ if (latest.size === 0)
259
+ return [];
260
+ const addresses = Array.from(latest.keys()).sort((left, right) => left - right);
261
+ const steps = [];
262
+ const emitRun = (start, end) => {
263
+ const length = end - start;
264
+ if (length >= minRunLength) {
265
+ const values = new Uint16Array(length);
266
+ for (let index = 0;index < length; index += 1) {
267
+ values[index] = latest.get(addresses[start + index]);
268
+ }
269
+ steps.push({ kind: "multiple", address: addresses[start], values });
270
+ return;
271
+ }
272
+ for (let index = start;index < end; index += 1) {
273
+ const address = addresses[index];
274
+ steps.push({ kind: "single", address, value: latest.get(address) });
275
+ }
276
+ };
277
+ let runStart = 0;
278
+ for (let index = 1;index <= addresses.length; index += 1) {
279
+ const endsHere = index === addresses.length || addresses[index] !== addresses[index - 1] + 1 || index - runStart >= maxRegistersPerWrite;
280
+ if (endsHere) {
281
+ emitRun(runStart, index);
282
+ runStart = index;
283
+ }
284
+ }
285
+ return steps;
286
+ };
287
+ var planReads = (addresses, options = {}) => {
288
+ const maxRegistersPerRead = options.maxRegistersPerRead ?? MODBUS_MAX_READ_REGISTERS;
289
+ const maxGap = options.maxGap ?? 0;
290
+ assertTransactionLimit("maxRegistersPerRead", maxRegistersPerRead, MODBUS_MAX_READ_REGISTERS);
291
+ if (!Number.isInteger(maxGap) || maxGap < 0) {
292
+ throw new RangeError(`maxGap must be a non-negative integer, got ${maxGap}`);
293
+ }
294
+ for (const address of addresses)
295
+ assertAddress(address);
296
+ const sorted = Array.from(new Set(addresses)).sort((left, right) => left - right);
297
+ const spans = [];
298
+ if (sorted.length > 0) {
299
+ let start = sorted[0];
300
+ let end = sorted[0];
301
+ for (let index = 1;index < sorted.length; index += 1) {
302
+ const address = sorted[index];
303
+ const fitsGap = address - end - 1 <= maxGap;
304
+ const fitsSpan = address - start + 1 <= maxRegistersPerRead;
305
+ if (fitsGap && fitsSpan) {
306
+ end = address;
307
+ continue;
308
+ }
309
+ spans.push({ address: start, quantity: end - start + 1 });
310
+ start = address;
311
+ end = address;
312
+ }
313
+ spans.push({ address: start, quantity: end - start + 1 });
314
+ }
315
+ const locate = (address) => {
316
+ if (!Number.isInteger(address) || address < 0 || address > MAX_REGISTER_ADDRESS) {
317
+ return;
318
+ }
319
+ let low = 0;
320
+ let high = spans.length - 1;
321
+ while (low <= high) {
322
+ const middle = low + high >> 1;
323
+ const span = spans[middle];
324
+ if (address < span.address)
325
+ high = middle - 1;
326
+ else if (address >= span.address + span.quantity)
327
+ low = middle + 1;
328
+ else
329
+ return { span: middle, offset: address - span.address };
330
+ }
331
+ return;
332
+ };
333
+ return { spans, locate };
334
+ };
335
+ // src/register-cache.ts
336
+ var assertRegisterAddress = (address) => {
337
+ if (!Number.isInteger(address) || address < 0 || address > 65535) {
338
+ throw new RangeError(`address must be an integer from 0 to 65535, got ${address}`);
339
+ }
340
+ };
341
+ var createRegisterCache = () => {
342
+ const held = new Map;
343
+ const keyOf = (unitId, address) => `${unitId}:${address}`;
344
+ const unitGenerations = new Map;
345
+ let busGeneration = 0;
346
+ const generationOf = (unitId) => (unitGenerations.get(unitId) ?? 0) + busGeneration;
347
+ return {
348
+ filter: (unitId, writes) => {
349
+ const latest = new Map;
350
+ const suppressed = [];
351
+ for (const write of writes) {
352
+ assertRegisterAddress(write.address);
353
+ const encoded = encodeRegisterValue(write.value);
354
+ const superseded = latest.get(write.address);
355
+ if (superseded !== undefined)
356
+ suppressed.push(superseded.write);
357
+ latest.set(write.address, { write, encoded });
358
+ }
359
+ const pending = [];
360
+ for (const { encoded, write } of latest.values()) {
361
+ if (held.get(keyOf(unitId, write.address)) === encoded) {
362
+ suppressed.push(write);
363
+ } else {
364
+ pending.push(write);
365
+ }
366
+ }
367
+ return { pending, suppressed };
368
+ },
369
+ generationOf,
370
+ observe: (unitId, address, value, generation) => {
371
+ assertRegisterAddress(address);
372
+ if (generation !== undefined && generation !== generationOf(unitId))
373
+ return;
374
+ held.set(keyOf(unitId, address), encodeRegisterValue(value));
375
+ },
376
+ invalidate: (unitId) => {
377
+ if (unitId === undefined) {
378
+ held.clear();
379
+ busGeneration += 1;
380
+ return;
381
+ }
382
+ unitGenerations.set(unitId, (unitGenerations.get(unitId) ?? 0) + 1);
383
+ const prefix = `${unitId}:`;
384
+ for (const key of held.keys()) {
385
+ if (key.startsWith(prefix))
386
+ held.delete(key);
387
+ }
388
+ },
389
+ get size() {
390
+ return held.size;
391
+ }
392
+ };
393
+ };
394
+ // src/write-debouncer.ts
395
+ import { Clock, Deferred, Duration as Duration3, Effect as Effect3, Fiber, Semaphore } from "effect";
396
+ 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));
399
+ const scope = yield* Effect3.scope;
400
+ const lock = yield* Semaphore.make(1);
401
+ const flushLock = yield* Semaphore.make(1);
402
+ const held = new Map;
403
+ let timer;
404
+ let nextTimerId = 0;
405
+ let openedAtMs;
406
+ let closed = false;
407
+ const scopeClosedError = () => {
408
+ const message = "The write debouncer scope has been closed";
409
+ return new ModbusNotConnectedError({ cause: new Error(message), message });
410
+ };
411
+ const ensureOpen = Effect3.suspend(() => closed ? Effect3.fail(scopeClosedError()) : Effect3.void);
412
+ const validateWrites = (writes) => Effect3.try({
413
+ try: () => {
414
+ for (const write of writes) {
415
+ if (!Number.isInteger(write.address) || write.address < 0 || write.address > 65535) {
416
+ throw new RangeError(`address must be an integer from 0 to 65535, got ${write.address}`);
417
+ }
418
+ encodeRegisterValue(write.value);
419
+ }
420
+ },
421
+ catch: (cause) => {
422
+ const error = cause instanceof Error ? cause : new Error(String(cause));
423
+ return new ModbusInvalidArgumentError({ cause: error, message: error.message });
424
+ }
425
+ });
426
+ const flushPending = (expectedTimer) => flushLock.withPermits(1)(Effect3.uninterruptibleMask((restore) => Effect3.gen(function* () {
427
+ const taken = yield* lock.withPermits(1)(Effect3.sync(() => {
428
+ if (expectedTimer !== undefined && timer?.id !== expectedTimer.id)
429
+ return;
430
+ const activeTimer = timer;
431
+ timer = undefined;
432
+ const batch = Array.from(held.values());
433
+ held.clear();
434
+ openedAtMs = undefined;
435
+ return { activeTimer, batch };
436
+ }));
437
+ if (taken === undefined)
438
+ return;
439
+ if (expectedTimer === undefined)
440
+ taken.activeTimer?.fiber?.interruptUnsafe();
441
+ if (taken.batch.length === 0)
442
+ return;
443
+ const exit = yield* Effect3.exit(restore(options.flush(taken.batch.map((entry) => ({
444
+ address: entry.write.address,
445
+ value: entry.write.value,
446
+ attributes: entry.attributes
447
+ })))));
448
+ yield* Effect3.forEach(taken.batch.flatMap((entry) => entry.waiters), (waiter) => Deferred.done(waiter, exit), { discard: true });
449
+ })));
450
+ const enqueue = (writes, attributes, restartTimer) => Effect3.gen(function* () {
451
+ const waiter = yield* Deferred.make();
452
+ yield* lock.withPermits(1)(Effect3.gen(function* () {
453
+ if (closed)
454
+ return yield* scopeClosedError();
455
+ for (const write of writes) {
456
+ const superseded = held.get(write.address);
457
+ held.set(write.address, {
458
+ write,
459
+ attributes,
460
+ waiters: [...superseded?.waiters ?? [], waiter]
461
+ });
462
+ }
463
+ const now = yield* Clock.currentTimeMillis;
464
+ openedAtMs ??= now;
465
+ if (!restartTimer)
466
+ return;
467
+ const delayMs = Math.max(0, Math.min(windowMs, openedAtMs + maxHoldMs - now));
468
+ const previous = timer;
469
+ const state = { id: nextTimerId++ };
470
+ timer = state;
471
+ const forked = yield* Effect3.forkIn(Effect3.sleep(Duration3.millis(delayMs)).pipe(Effect3.andThen(flushPending(state))), scope);
472
+ state.fiber = forked;
473
+ if (timer?.id !== state.id)
474
+ forked.interruptUnsafe();
475
+ previous?.fiber?.interruptUnsafe();
476
+ }));
477
+ return waiter;
478
+ });
479
+ const issueDirectly = (writes, attributes) => flushLock.withPermits(1)(options.flush(writes.map((write) => ({ address: write.address, value: write.value, attributes }))));
480
+ const writeAll = (writes, attributes) => {
481
+ if (writes.length === 0)
482
+ return Effect3.void;
483
+ return Effect3.andThen(ensureOpen, Effect3.andThen(validateWrites(writes), windowMs <= 0 ? issueDirectly(writes, attributes) : Effect3.flatMap(enqueue(writes, attributes, true), Deferred.await)));
484
+ };
485
+ const writeAllNow = (writes, attributes) => {
486
+ if (writes.length === 0)
487
+ return Effect3.void;
488
+ return Effect3.andThen(ensureOpen, Effect3.andThen(validateWrites(writes), windowMs <= 0 ? issueDirectly(writes, attributes) : Effect3.uninterruptibleMask((restore) => Effect3.gen(function* () {
489
+ const waiter = yield* enqueue(writes, attributes, false);
490
+ yield* Effect3.forkIn(flushPending(), scope);
491
+ return yield* restore(Deferred.await(waiter));
492
+ }))));
493
+ };
494
+ const write = (write, attributes) => writeAll([write], attributes);
495
+ const writeNow = (write, attributes) => writeAllNow([write], attributes);
496
+ const flush = Effect3.uninterruptibleMask((restore) => Effect3.gen(function* () {
497
+ const fiber = yield* Effect3.forkIn(flushPending(), scope);
498
+ yield* restore(Fiber.await(fiber));
499
+ }));
500
+ yield* Effect3.addFinalizer(() => Effect3.andThen(Effect3.sync(() => {
501
+ closed = true;
502
+ }), lock.withPermits(1)(Effect3.sync(() => {
503
+ const abandoned = Array.from(held.values());
504
+ held.clear();
505
+ openedAtMs = undefined;
506
+ return abandoned;
507
+ })).pipe(Effect3.flatMap((abandoned) => abandoned.length === 0 ? Effect3.void : Effect3.logWarning(`Discarding ${abandoned.length} pending register write(s) at shutdown`).pipe(Effect3.andThen(Effect3.forEach(abandoned.flatMap((entry) => entry.waiters), Deferred.interrupt, { discard: true })))))));
508
+ return {
509
+ write,
510
+ writeNow,
511
+ writeAll,
512
+ writeAllNow,
513
+ flush,
514
+ get pending() {
515
+ return held.size;
516
+ }
517
+ };
518
+ });
519
+ // src/read-debouncer.ts
520
+ import { Deferred as Deferred2, Duration as Duration4, Effect as Effect4, Exit, Fiber as Fiber2, Semaphore as Semaphore2 } from "effect";
521
+ var malformedResponse = (message) => new ModbusTransportError({ cause: new Error(message), message });
522
+ var shortResponse = (address) => malformedResponse(`Response did not carry a value for register ${address}`);
523
+ var settleWith = (value, address) => (waiter) => value === undefined ? Deferred2.fail(waiter, shortResponse(address)) : Deferred2.succeed(waiter, value);
524
+ var createReadDebouncer = (options) => Effect4.gen(function* () {
525
+ const windowMs = Duration4.toMillis(Duration4.fromInputUnsafe(options.window));
526
+ const scope = yield* Effect4.scope;
527
+ const lock = yield* Semaphore2.make(1);
528
+ const collected = new Map;
529
+ let timer;
530
+ let nextTimerId = 0;
531
+ let closed = false;
532
+ const scopeClosedError = () => {
533
+ const message = "The read debouncer scope has been closed";
534
+ return new ModbusNotConnectedError({ cause: new Error(message), message });
535
+ };
536
+ const ensureOpen = Effect4.suspend(() => closed ? Effect4.fail(scopeClosedError()) : Effect4.void);
537
+ const validateAddresses = (addresses) => Effect4.try({
538
+ try: () => {
539
+ for (const address of addresses) {
540
+ if (!Number.isInteger(address) || address < 0 || address > 65535) {
541
+ throw new RangeError(`address must be an integer from 0 to 65535, got ${address}`);
542
+ }
543
+ }
544
+ },
545
+ catch: (cause) => {
546
+ const error = cause instanceof Error ? cause : new Error(String(cause));
547
+ return new ModbusInvalidArgumentError({ cause: error, message: error.message });
548
+ }
549
+ });
550
+ const issue = (addresses) => Effect4.gen(function* () {
551
+ const plan = yield* Effect4.try({
552
+ try: () => planReads(addresses, options.plan),
553
+ catch: (cause) => {
554
+ const error = cause instanceof Error ? cause : new Error(String(cause));
555
+ return new ModbusInvalidArgumentError({ cause: error, message: error.message });
556
+ }
557
+ });
558
+ const responses = yield* options.fetch(plan.spans);
559
+ if (responses.length !== plan.spans.length) {
560
+ return yield* malformedResponse(`Expected ${plan.spans.length} read response(s), received ${responses.length}`);
561
+ }
562
+ for (let index = 0;index < plan.spans.length; index += 1) {
563
+ const span = plan.spans[index];
564
+ const response = responses[index];
565
+ if (response.length !== span.quantity) {
566
+ return yield* malformedResponse(`Expected ${span.quantity} register(s) for span at address ${span.address}, ` + `received ${response.length}`);
567
+ }
568
+ }
569
+ return (address) => {
570
+ const location = plan.locate(address);
571
+ return location === undefined ? undefined : responses[location.span]?.[location.offset];
572
+ };
573
+ });
574
+ const issueDirectly = (addresses) => Effect4.flatMap(issue(addresses), (valueOf) => Effect4.forEach(addresses, (address) => {
575
+ const value = valueOf(address);
576
+ return value === undefined ? Effect4.fail(shortResponse(address)) : Effect4.succeed(value);
577
+ }));
578
+ const flushPending = (expectedTimer) => Effect4.uninterruptibleMask((restore) => Effect4.gen(function* () {
579
+ const taken = yield* lock.withPermits(1)(Effect4.sync(() => {
580
+ if (expectedTimer !== undefined && timer?.id !== expectedTimer.id)
581
+ return;
582
+ const activeTimer = timer;
583
+ timer = undefined;
584
+ const batch = Array.from(collected.entries());
585
+ collected.clear();
586
+ return { activeTimer, batch };
587
+ }));
588
+ if (taken === undefined)
589
+ return;
590
+ if (expectedTimer === undefined)
591
+ taken.activeTimer?.fiber?.interruptUnsafe();
592
+ if (taken.batch.length === 0)
593
+ return;
594
+ const exit = yield* Effect4.exit(restore(issue(taken.batch.map(([address]) => address))));
595
+ yield* Effect4.forEach(taken.batch, ([address, waiters]) => {
596
+ const settle = Exit.isSuccess(exit) ? settleWith(exit.value(address), address) : (waiter) => Deferred2.failCause(waiter, exit.cause);
597
+ return Effect4.forEach(waiters, settle, { discard: true });
598
+ }, { discard: true });
599
+ }));
600
+ const enqueue = (addresses, startTimer) => Effect4.gen(function* () {
601
+ const waiters = yield* Effect4.forEach(addresses, () => Deferred2.make());
602
+ yield* lock.withPermits(1)(Effect4.gen(function* () {
603
+ if (closed)
604
+ return yield* scopeClosedError();
605
+ addresses.forEach((address, index) => {
606
+ const existing = collected.get(address);
607
+ if (existing === undefined)
608
+ collected.set(address, [waiters[index]]);
609
+ else
610
+ existing.push(waiters[index]);
611
+ });
612
+ }).pipe(Effect4.andThen(Effect4.suspend(() => {
613
+ if (!startTimer || timer !== undefined)
614
+ return Effect4.void;
615
+ const state = { id: nextTimerId++ };
616
+ timer = state;
617
+ return Effect4.map(Effect4.forkIn(Effect4.sleep(Duration4.millis(windowMs)).pipe(Effect4.andThen(flushPending(state))), scope), (forked) => {
618
+ state.fiber = forked;
619
+ if (timer?.id !== state.id)
620
+ forked.interruptUnsafe();
621
+ });
622
+ }))));
623
+ return waiters;
624
+ });
625
+ const readAll = (addresses) => {
626
+ if (addresses.length === 0)
627
+ return Effect4.succeed([]);
628
+ return Effect4.andThen(ensureOpen, Effect4.andThen(validateAddresses(addresses), windowMs <= 0 ? issueDirectly(addresses) : Effect4.flatMap(enqueue(addresses, true), (waiters) => Effect4.forEach(waiters, Deferred2.await))));
629
+ };
630
+ const readAllNow = (addresses) => {
631
+ if (addresses.length === 0)
632
+ return Effect4.succeed([]);
633
+ return Effect4.andThen(ensureOpen, Effect4.andThen(validateAddresses(addresses), windowMs <= 0 ? issueDirectly(addresses) : Effect4.uninterruptibleMask((restore) => Effect4.gen(function* () {
634
+ const waiters = yield* enqueue(addresses, false);
635
+ yield* Effect4.forkIn(flushPending(), scope);
636
+ return yield* restore(Effect4.forEach(waiters, Deferred2.await));
637
+ }))));
638
+ };
639
+ const read = (address) => Effect4.map(readAll([address]), (values) => values[0]);
640
+ const readNow = (address) => Effect4.map(readAllNow([address]), (values) => values[0]);
641
+ const flush = Effect4.uninterruptibleMask((restore) => Effect4.gen(function* () {
642
+ const fiber = yield* Effect4.forkIn(flushPending(), scope);
643
+ yield* restore(Fiber2.await(fiber));
644
+ }));
645
+ yield* Effect4.addFinalizer(() => Effect4.andThen(Effect4.sync(() => {
646
+ closed = true;
647
+ }), lock.withPermits(1)(Effect4.sync(() => {
648
+ const abandoned = Array.from(collected.values()).flat();
649
+ collected.clear();
650
+ return abandoned;
651
+ })).pipe(Effect4.flatMap((abandoned) => abandoned.length === 0 ? Effect4.void : Effect4.logWarning(`Discarding ${abandoned.length} pending register read(s) at shutdown`).pipe(Effect4.andThen(Effect4.forEach(abandoned, Deferred2.interrupt, { discard: true })))))));
652
+ return {
653
+ read,
654
+ readNow,
655
+ readAll,
656
+ readAllNow,
657
+ flush,
658
+ get pending() {
659
+ return collected.size;
660
+ }
661
+ };
662
+ });
663
+ // src/batching-client.ts
664
+ import {
665
+ Deferred as Deferred3,
666
+ Effect as Effect5,
667
+ Exit as Exit2,
668
+ Fiber as Fiber3,
669
+ Scope,
670
+ Semaphore as Semaphore3,
671
+ Stream,
672
+ SubscriptionRef as SubscriptionRef2
673
+ } from "effect";
674
+
675
+ // src/span-attributes.ts
676
+ var mergeSpanAttributes = (sources) => {
677
+ const collected = new Map;
678
+ for (const source of sources) {
679
+ if (source === undefined)
680
+ continue;
681
+ for (const [key, value] of Object.entries(source)) {
682
+ const entry = collected.get(key);
683
+ if (entry === undefined)
684
+ collected.set(key, { first: value, values: new Set([String(value)]) });
685
+ else
686
+ entry.values.add(String(value));
687
+ }
688
+ }
689
+ return Object.fromEntries(Array.from(collected, ([key, entry]) => [
690
+ key,
691
+ entry.values.size === 1 ? entry.first : Array.from(entry.values).join(",")
692
+ ]));
693
+ };
694
+
695
+ // src/batching-client.ts
696
+ var makeBatchingClient = (options) => Effect5.gen(function* () {
697
+ const { unitId, client, cache } = options;
698
+ const scope = yield* Effect5.scope;
699
+ const invalidArgument = (cause) => {
700
+ const error = cause instanceof Error ? cause : new Error(String(cause));
701
+ return new ModbusInvalidArgumentError({ cause: error, message: error.message });
702
+ };
703
+ const own = (registerCount, transactionCount, suppressedCount) => ({
704
+ "modbus.unit_ids": String(unitId),
705
+ "modbus.register_count": registerCount,
706
+ "modbus.transaction_count": transactionCount,
707
+ "modbus.suppressed_count": suppressedCount
708
+ });
709
+ const issueWrites = (batch) => Effect5.gen(function* () {
710
+ const proposed = batch.map((entry) => ({ address: entry.address, value: entry.value }));
711
+ const filtered = yield* Effect5.try({
712
+ try: () => cache?.filter(unitId, proposed),
713
+ catch: invalidArgument
714
+ });
715
+ const pending = filtered?.pending ?? proposed;
716
+ if (pending.length === 0)
717
+ return;
718
+ const plan = yield* Effect5.try({
719
+ try: () => {
720
+ const steps = planWrites(pending, options.plan?.writes);
721
+ if (pending.length === proposed.length)
722
+ return { steps, written: proposed };
723
+ const whole = planWrites(proposed, options.plan?.writes);
724
+ return whole.length < steps.length ? { steps: whole, written: proposed } : { steps, written: pending };
725
+ },
726
+ catch: invalidArgument
727
+ });
728
+ const { steps, written } = plan;
729
+ const carried = new Set(written.map((write) => write.address));
730
+ const suppressed = proposed.length - written.length;
731
+ yield* Effect5.forEach(steps, (step) => Effect5.suspend(() => {
732
+ const generation = cache?.generationOf(unitId);
733
+ return step.kind === "single" ? Effect5.tap(client.writeSingleRegister({ address: step.address, value: step.value }), () => Effect5.sync(() => cache?.observe(unitId, step.address, step.value, generation))) : Effect5.tap(client.writeMultipleRegisters({ address: step.address, values: step.values }), () => Effect5.sync(() => step.values.forEach((value, index) => cache?.observe(unitId, step.address + index, value, generation))));
734
+ }), { discard: true }).pipe(Effect5.tapError(() => Effect5.sync(() => cache?.invalidate(unitId))), Effect5.withSpan("modbus.write", {
735
+ kind: "client",
736
+ attributes: {
737
+ ...mergeSpanAttributes(batch.filter((entry) => carried.has(entry.address)).map((entry) => entry.attributes)),
738
+ ...own(written.length, steps.length, suppressed)
739
+ }
740
+ }));
741
+ });
742
+ const issueReads = (name, read) => (spans) => Effect5.forEach(spans, (span) => read({ address: span.address, quantity: span.quantity })).pipe(Effect5.withSpan(name, {
743
+ kind: "client",
744
+ attributes: own(spans.reduce((total, span) => total + span.quantity, 0), spans.length, 0)
745
+ }));
746
+ const writes = yield* createWriteDebouncer({
747
+ window: options.debounce?.writes?.window ?? 0,
748
+ maxHold: options.debounce?.writes?.maxHold,
749
+ flush: issueWrites
750
+ });
751
+ const holding = yield* createReadDebouncer({
752
+ window: options.debounce?.reads?.window ?? 0,
753
+ plan: options.plan?.reads,
754
+ fetch: issueReads("modbus.read", client.readHoldingRegisters)
755
+ });
756
+ const inputs = yield* createReadDebouncer({
757
+ window: options.debounce?.reads?.window ?? 0,
758
+ plan: options.plan?.reads,
759
+ fetch: issueReads("modbus.read", client.readInputRegisters)
760
+ });
761
+ const readerOf = (debouncer) => ({
762
+ read: debouncer.read,
763
+ readNow: debouncer.readNow,
764
+ readAll: debouncer.readAll,
765
+ readAllNow: debouncer.readAllNow
766
+ });
767
+ const flush = Effect5.uninterruptibleMask((restore) => Effect5.gen(function* () {
768
+ const fiber = yield* Effect5.forkIn(Effect5.andThen(writes.flush, Effect5.andThen(holding.flush, inputs.flush)), scope);
769
+ yield* restore(Fiber3.await(fiber));
770
+ }));
771
+ const onShutdown = (action) => Effect5.addFinalizer(() => action.pipe(Effect5.tapError((error) => Effect5.logError(`Shutdown action for unit ${unitId} failed: ${error.message}`)), Effect5.orDie));
772
+ return {
773
+ unitId,
774
+ cache,
775
+ write: writes.write,
776
+ writeNow: writes.writeNow,
777
+ writeAll: writes.writeAll,
778
+ writeAllNow: writes.writeAllNow,
779
+ ...readerOf(holding),
780
+ inputs: readerOf(inputs),
781
+ flush,
782
+ onShutdown
783
+ };
784
+ });
785
+ var createBatchingRegistry = (deps) => {
786
+ const clientScope = Scope.forkUnsafe(deps.scope);
787
+ const clients = new Map;
788
+ const creating = new Map;
789
+ const writeLocks = new Map;
790
+ let shared;
791
+ let closed = false;
792
+ let watchingScope = false;
793
+ const scopeClosedError = () => {
794
+ const message = "The batching registry scope has been closed";
795
+ return new ModbusNotConnectedError({ cause: new Error(message), message });
796
+ };
797
+ const watchScope = Effect5.suspend(() => {
798
+ if (watchingScope)
799
+ return Effect5.void;
800
+ watchingScope = true;
801
+ return Scope.addFinalizer(clientScope, Effect5.gen(function* () {
802
+ closed = true;
803
+ const pending = Array.from(creating.values());
804
+ creating.clear();
805
+ yield* Effect5.forEach(pending, (deferred) => Deferred3.fail(deferred, scopeClosedError()), {
806
+ discard: true
807
+ });
808
+ }));
809
+ });
810
+ const sharedCache = Effect5.fnUntraced(function* () {
811
+ if (shared)
812
+ return shared;
813
+ const cache = createRegisterCache();
814
+ shared = cache;
815
+ yield* Effect5.forkIn(Stream.runForEach(SubscriptionRef2.changes(deps.connectionState), (state) => Effect5.sync(() => {
816
+ if (!ConnectionState.$is("Connected")(state))
817
+ cache.invalidate();
818
+ })), clientScope);
819
+ return cache;
820
+ });
821
+ const writeLockFor = (unitId) => {
822
+ const existing = writeLocks.get(unitId);
823
+ if (existing)
824
+ return existing;
825
+ const created = Semaphore3.makeUnsafe(1);
826
+ writeLocks.set(unitId, created);
827
+ return created;
828
+ };
829
+ const guardRawWrites = (unitId, operations) => {
830
+ const write = (operation) => writeLockFor(unitId).withPermits(1)(Effect5.suspend(() => {
831
+ if (!clients.has(unitId) && !creating.has(unitId))
832
+ return operation();
833
+ const message = `Raw register writes are disabled for unit ${unitId} because a batching client exists. ` + `Use the batching client for every holding-register write on that unit.`;
834
+ return Effect5.fail(new ModbusInvalidArgumentError({ cause: new Error(message), message }));
835
+ }));
836
+ return {
837
+ ...operations,
838
+ writeSingleRegister: (options) => write(() => operations.writeSingleRegister(options)),
839
+ writeMultipleRegisters: (options) => write(() => operations.writeMultipleRegisters(options)),
840
+ readWriteMultipleRegisters: (options) => write(() => operations.readWriteMultipleRegisters(options))
841
+ };
842
+ };
843
+ const alreadyDeclaredError = (unitId) => {
844
+ 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 });
846
+ };
847
+ const notDeclaredError = (unitId) => {
848
+ const message = `No batching client is declared for unit ${unitId}. Declare one with ` + `withBatchingClient(${unitId}) before reaching for it.`;
849
+ return new ModbusInvalidArgumentError({ cause: new Error(message), message });
850
+ };
851
+ return {
852
+ withBatchingClient: (unitId, options) => Effect5.uninterruptibleMask((restore) => Effect5.gen(function* () {
853
+ yield* watchScope;
854
+ if (closed)
855
+ return yield* scopeClosedError();
856
+ const configuredCache = options?.cache;
857
+ const injected = configuredCache === undefined || configuredCache === true || configuredCache === false ? undefined : configuredCache;
858
+ const fresh = yield* Deferred3.make();
859
+ const election = yield* Effect5.sync(() => {
860
+ if (closed)
861
+ return { _tag: "Closed" };
862
+ if (clients.has(unitId) || creating.has(unitId)) {
863
+ return { _tag: "Declared" };
864
+ }
865
+ creating.set(unitId, fresh);
866
+ return { _tag: "Leader" };
867
+ });
868
+ if (election._tag === "Closed")
869
+ return yield* scopeClosedError();
870
+ if (election._tag === "Declared")
871
+ return yield* alreadyDeclaredError(unitId);
872
+ const build = writeLockFor(unitId).withPermits(1)(Effect5.gen(function* () {
873
+ const raw = yield* deps.withClient(unitId, { retry: options?.retry });
874
+ let cache;
875
+ if (options?.cache !== false)
876
+ cache = injected ?? (yield* sharedCache());
877
+ const client = yield* Scope.provide(makeBatchingClient({
878
+ unitId,
879
+ client: raw,
880
+ cache,
881
+ debounce: options?.debounce,
882
+ plan: options?.plan
883
+ }), clientScope);
884
+ return { cache, client };
885
+ }));
886
+ yield* Effect5.forkIn(Effect5.uninterruptible(Effect5.flatMap(Effect5.exit(Effect5.interruptible(build)), (exit) => Effect5.andThen(Effect5.sync(() => {
887
+ creating.delete(unitId);
888
+ if (Exit2.isSuccess(exit))
889
+ clients.set(unitId, exit.value.client);
890
+ }), Deferred3.done(fresh, Exit2.hasInterrupts(exit) ? Exit2.fail(scopeClosedError()) : Exit2.map(exit, ({ client }) => client))))), clientScope);
891
+ return yield* restore(Deferred3.await(fresh));
892
+ })),
893
+ batchingClient: (unitId) => Effect5.uninterruptibleMask((restore) => Effect5.gen(function* () {
894
+ if (closed)
895
+ return yield* scopeClosedError();
896
+ const existing = clients.get(unitId);
897
+ if (existing)
898
+ return existing;
899
+ const pending = creating.get(unitId);
900
+ if (pending)
901
+ return yield* restore(Deferred3.await(pending));
902
+ return yield* notDeclaredError(unitId);
903
+ })),
904
+ guardRawWrites
905
+ };
906
+ };
209
907
  // src/AsciiTransportService.ts
210
908
  import { Context, Layer } from "effect";
211
909
 
212
910
  // src/mocks.ts
213
- import { Effect as Effect4, Schema, SubscriptionRef as SubscriptionRef2 } from "effect";
911
+ import { Effect as Effect7, Exit as Exit3, Schema, Scope as Scope2, SubscriptionRef as SubscriptionRef3 } from "effect";
214
912
  import {
215
913
  CoilState
216
914
  } from "modbus-rs";
217
915
 
218
916
  // src/modbus-client.ts
219
- import { Effect as Effect3 } from "effect";
220
- var wrap = (try_) => Effect3.tryPromise({
917
+ import { Effect as Effect6 } from "effect";
918
+ var wrap = (try_) => Effect6.tryPromise({
221
919
  try: try_,
222
- catch: (error) => toModbusError(error)
920
+ catch: (cause) => toModbusError(cause instanceof Error ? cause : new Error(String(cause)))
223
921
  });
224
- var makeEffectModbusClient = (client) => ({
922
+ var createEffectModbusClient = (client) => ({
225
923
  readHoldingRegisters: (opts) => wrap(() => client.readHoldingRegisters(opts)),
226
924
  readInputRegisters: (opts) => wrap(() => client.readInputRegisters(opts)),
227
925
  writeSingleRegister: (opts) => wrap(() => client.writeSingleRegister(opts)),
@@ -240,7 +938,7 @@ var makeEffectModbusClient = (client) => ({
240
938
  });
241
939
  var withResilience = (operations, resilience) => {
242
940
  const run = (operation) => {
243
- const attempt = Effect3.andThen(resilience.guard, Effect3.suspend(operation)).pipe(Effect3.tapError((error) => resilience.report(error)));
941
+ const attempt = Effect6.andThen(resilience.guard, Effect6.suspend(operation)).pipe(Effect6.tap(() => resilience.onSuccess ?? Effect6.void), Effect6.tapError((error) => resilience.report(error)));
244
942
  return resilience.policy ? retryModbus(resilience.policy)(attempt) : attempt;
245
943
  };
246
944
  return {
@@ -278,10 +976,10 @@ var RegisterDefinition = Schema.Struct({
278
976
  });
279
977
  var SlaveDeviceDefinition = Schema.Struct({
280
978
  unitId: Schema.Number,
281
- coils: Schema.Array(CoilDefinition).pipe(Schema.withDecodingDefaultType(Effect4.succeed([]))),
282
- discreteInputs: Schema.Array(DiscreteInputDefinition).pipe(Schema.withDecodingDefaultType(Effect4.succeed([]))),
283
- holdingRegisters: Schema.Array(RegisterDefinition).pipe(Schema.withDecodingDefaultType(Effect4.succeed([]))),
284
- inputRegisters: Schema.Array(RegisterDefinition).pipe(Schema.withDecodingDefaultType(Effect4.succeed([])))
979
+ coils: Schema.Array(CoilDefinition).pipe(Schema.withDecodingDefaultType(Effect7.succeed([]))),
980
+ discreteInputs: Schema.Array(DiscreteInputDefinition).pipe(Schema.withDecodingDefaultType(Effect7.succeed([]))),
981
+ holdingRegisters: Schema.Array(RegisterDefinition).pipe(Schema.withDecodingDefaultType(Effect7.succeed([]))),
982
+ inputRegisters: Schema.Array(RegisterDefinition).pipe(Schema.withDecodingDefaultType(Effect7.succeed([])))
285
983
  });
286
984
  var SlaveDeviceDefinitions = Schema.Array(SlaveDeviceDefinition);
287
985
  var buildCoils = (defs) => {
@@ -309,8 +1007,8 @@ var failOutOfRange = (label, address, quantity) => new ModbusInvalidArgumentErro
309
1007
  message: quantity !== undefined ? `${label} read out of range: address=${address}, quantity=${quantity}` : `${label} write out of range: address=${address}`
310
1008
  });
311
1009
  var makeMockModbusClient = (state, unitId) => ({
312
- readCoils: Effect4.fnUntraced(function* (opts) {
313
- yield* Effect4.logDebug(`[Mock] unitId=${unitId} readCoils`, opts);
1010
+ readCoils: Effect7.fnUntraced(function* (opts) {
1011
+ yield* Effect7.logDebug(`[Mock] unitId=${unitId} readCoils`, opts);
314
1012
  if (opts.address + opts.quantity > state.maxCoilAddress + 1) {
315
1013
  return yield* failOutOfRange("Coil", opts.address, opts.quantity);
316
1014
  }
@@ -320,8 +1018,8 @@ var makeMockModbusClient = (state, unitId) => ({
320
1018
  }
321
1019
  return result;
322
1020
  }),
323
- readDiscreteInputs: Effect4.fnUntraced(function* (opts) {
324
- yield* Effect4.logDebug(`[Mock] unitId=${unitId} readDiscreteInputs`, opts);
1021
+ readDiscreteInputs: Effect7.fnUntraced(function* (opts) {
1022
+ yield* Effect7.logDebug(`[Mock] unitId=${unitId} readDiscreteInputs`, opts);
325
1023
  if (opts.address + opts.quantity > state.maxDiscreteAddress + 1) {
326
1024
  return yield* failOutOfRange("DiscreteInput", opts.address, opts.quantity);
327
1025
  }
@@ -331,8 +1029,8 @@ var makeMockModbusClient = (state, unitId) => ({
331
1029
  }
332
1030
  return result;
333
1031
  }),
334
- readHoldingRegisters: Effect4.fnUntraced(function* (opts) {
335
- yield* Effect4.logDebug(`[Mock] unitId=${unitId} readHoldingRegisters`, opts);
1032
+ readHoldingRegisters: Effect7.fnUntraced(function* (opts) {
1033
+ yield* Effect7.logDebug(`[Mock] unitId=${unitId} readHoldingRegisters`, opts);
336
1034
  if (opts.address + opts.quantity > state.maxHoldingAddress + 1) {
337
1035
  return yield* failOutOfRange("HoldingRegister", opts.address, opts.quantity);
338
1036
  }
@@ -342,8 +1040,8 @@ var makeMockModbusClient = (state, unitId) => ({
342
1040
  }
343
1041
  return new Uint16Array(result);
344
1042
  }),
345
- readInputRegisters: Effect4.fnUntraced(function* (opts) {
346
- yield* Effect4.logDebug(`[Mock] unitId=${unitId} readInputRegisters`, opts);
1043
+ readInputRegisters: Effect7.fnUntraced(function* (opts) {
1044
+ yield* Effect7.logDebug(`[Mock] unitId=${unitId} readInputRegisters`, opts);
347
1045
  if (opts.address + opts.quantity > state.maxInputAddress + 1) {
348
1046
  return yield* failOutOfRange("InputRegister", opts.address, opts.quantity);
349
1047
  }
@@ -353,15 +1051,15 @@ var makeMockModbusClient = (state, unitId) => ({
353
1051
  }
354
1052
  return new Uint16Array(result);
355
1053
  }),
356
- writeSingleCoil: Effect4.fnUntraced(function* (opts) {
357
- yield* Effect4.logDebug(`[Mock] unitId=${unitId} writeSingleCoil`, opts);
1054
+ writeSingleCoil: Effect7.fnUntraced(function* (opts) {
1055
+ yield* Effect7.logDebug(`[Mock] unitId=${unitId} writeSingleCoil`, opts);
358
1056
  if (opts.address > state.maxCoilAddress) {
359
1057
  return yield* failOutOfRange("Coil", opts.address);
360
1058
  }
361
1059
  state.coils.set(opts.address, opts.value === CoilState.On);
362
1060
  }),
363
- writeMultipleCoils: Effect4.fnUntraced(function* (opts) {
364
- yield* Effect4.logDebug(`[Mock] unitId=${unitId} writeMultipleCoils`, opts);
1061
+ writeMultipleCoils: Effect7.fnUntraced(function* (opts) {
1062
+ yield* Effect7.logDebug(`[Mock] unitId=${unitId} writeMultipleCoils`, opts);
365
1063
  if (opts.address + opts.values.length > state.maxCoilAddress + 1) {
366
1064
  return yield* failOutOfRange("Coil", opts.address, opts.values.length);
367
1065
  }
@@ -369,15 +1067,15 @@ var makeMockModbusClient = (state, unitId) => ({
369
1067
  state.coils.set(opts.address + i, opts.values[i] === CoilState.On);
370
1068
  }
371
1069
  }),
372
- writeSingleRegister: Effect4.fnUntraced(function* (opts) {
373
- yield* Effect4.logDebug(`[Mock] unitId=${unitId} writeSingleRegister`, opts);
1070
+ writeSingleRegister: Effect7.fnUntraced(function* (opts) {
1071
+ yield* Effect7.logDebug(`[Mock] unitId=${unitId} writeSingleRegister`, opts);
374
1072
  if (opts.address > state.maxHoldingAddress) {
375
1073
  return yield* failOutOfRange("HoldingRegister", opts.address);
376
1074
  }
377
1075
  state.holdingRegisters.set(opts.address, opts.value);
378
1076
  }),
379
- writeMultipleRegisters: Effect4.fnUntraced(function* (opts) {
380
- yield* Effect4.logDebug(`[Mock] unitId=${unitId} writeMultipleRegisters`, opts);
1077
+ writeMultipleRegisters: Effect7.fnUntraced(function* (opts) {
1078
+ yield* Effect7.logDebug(`[Mock] unitId=${unitId} writeMultipleRegisters`, opts);
381
1079
  if (opts.address + opts.values.length > state.maxHoldingAddress + 1) {
382
1080
  return yield* failOutOfRange("HoldingRegister", opts.address, opts.values.length);
383
1081
  }
@@ -385,8 +1083,8 @@ var makeMockModbusClient = (state, unitId) => ({
385
1083
  state.holdingRegisters.set(opts.address + i, opts.values[i]);
386
1084
  }
387
1085
  }),
388
- readWriteMultipleRegisters: Effect4.fnUntraced(function* (opts) {
389
- yield* Effect4.logDebug(`[Mock] unitId=${unitId} readWriteMultipleRegisters`, opts);
1086
+ readWriteMultipleRegisters: Effect7.fnUntraced(function* (opts) {
1087
+ yield* Effect7.logDebug(`[Mock] unitId=${unitId} readWriteMultipleRegisters`, opts);
390
1088
  if (opts.writeAddress + opts.writeValues.length > state.maxHoldingAddress + 1) {
391
1089
  return yield* failOutOfRange("HoldingRegister", opts.writeAddress, opts.writeValues.length);
392
1090
  }
@@ -402,34 +1100,34 @@ var makeMockModbusClient = (state, unitId) => ({
402
1100
  }
403
1101
  return new Uint16Array(result);
404
1102
  }),
405
- readFifoQueue: Effect4.fnUntraced(function* (_opts) {
1103
+ readFifoQueue: Effect7.fnUntraced(function* (_opts) {
406
1104
  return yield* new ModbusInvalidArgumentError({
407
1105
  cause: new Error("FIFO queue not yet supported in mock"),
408
1106
  message: "FIFO queue not yet supported in mock"
409
1107
  });
410
1108
  }),
411
- readFileRecord: Effect4.fnUntraced(function* (_opts) {
1109
+ readFileRecord: Effect7.fnUntraced(function* (_opts) {
412
1110
  return yield* new ModbusInvalidArgumentError({
413
1111
  cause: new Error("File records not yet supported in mock"),
414
1112
  message: "File records not yet supported in mock"
415
1113
  });
416
1114
  }),
417
- writeFileRecord: Effect4.fnUntraced(function* (_opts) {
1115
+ writeFileRecord: Effect7.fnUntraced(function* (_opts) {
418
1116
  return yield* new ModbusInvalidArgumentError({
419
1117
  cause: new Error("File records not yet supported in mock"),
420
1118
  message: "File records not yet supported in mock"
421
1119
  });
422
1120
  }),
423
- readExceptionStatus: Effect4.fnUntraced(function* () {
424
- yield* Effect4.logDebug(`[Mock] unitId=${unitId} readExceptionStatus`);
1121
+ readExceptionStatus: Effect7.fnUntraced(function* () {
1122
+ yield* Effect7.logDebug(`[Mock] unitId=${unitId} readExceptionStatus`);
425
1123
  return 0;
426
1124
  }),
427
- diagnostics: Effect4.fnUntraced(function* (opts) {
428
- yield* Effect4.logDebug(`[Mock] unitId=${unitId} diagnostics`, opts);
1125
+ diagnostics: Effect7.fnUntraced(function* (opts) {
1126
+ yield* Effect7.logDebug(`[Mock] unitId=${unitId} diagnostics`, opts);
429
1127
  return { subFunction: opts.subFunction, data: new Uint16Array };
430
1128
  }),
431
- readDeviceIdentification: Effect4.fnUntraced(function* (opts) {
432
- yield* Effect4.logDebug(`[Mock] unitId=${unitId} readDeviceIdentification`, opts);
1129
+ readDeviceIdentification: Effect7.fnUntraced(function* (opts) {
1130
+ yield* Effect7.logDebug(`[Mock] unitId=${unitId} readDeviceIdentification`, opts);
433
1131
  return {
434
1132
  conformityLevel: 1,
435
1133
  moreFollows: false,
@@ -438,7 +1136,7 @@ var makeMockModbusClient = (state, unitId) => ({
438
1136
  };
439
1137
  })
440
1138
  });
441
- var makeMockTransport = (devices) => {
1139
+ var createMockTransport = (devices) => {
442
1140
  const deviceDefs = Schema.decodeUnknownSync(SlaveDeviceDefinitions)(devices);
443
1141
  const deviceStates = new Map;
444
1142
  for (const def of deviceDefs) {
@@ -457,89 +1155,168 @@ var makeMockTransport = (devices) => {
457
1155
  maxInputAddress: input.maxAddress
458
1156
  });
459
1157
  }
460
- return (options) => Effect4.gen(function* () {
461
- yield* Effect4.logDebug("Mock transport opened with devices:", deviceDefs);
462
- const connectionState = yield* SubscriptionRef2.make(ConnectionState.Connected());
463
- const supervised = options.reconnect ? resolveReconnect(options.reconnect) : null;
464
- const serviceScope = yield* Effect4.scope;
465
- const reconnectOnce = Effect4.andThen(Effect4.logDebug("Mock: reconnecting"), Effect4.suspend(() => {
1158
+ return (options) => Effect7.gen(function* () {
1159
+ yield* Effect7.logDebug("Mock transport opened with devices:", deviceDefs);
1160
+ const connectionState = yield* SubscriptionRef3.make(ConnectionState.Connected());
1161
+ const resolvedReconnect = resolveReconnect(options.reconnect ?? {});
1162
+ const supervised = options.reconnect ? resolvedReconnect : null;
1163
+ const serviceScope = yield* Effect7.scope;
1164
+ let closed = false;
1165
+ const transportClosed = () => new ModbusNotConnectedError({
1166
+ cause: new Error("Mock transport has been closed"),
1167
+ message: "Mock transport has been closed"
1168
+ });
1169
+ const ensureOpen = Effect7.suspend(() => closed ? Effect7.fail(transportClosed()) : Effect7.void);
1170
+ const closeTransport = Effect7.suspend(() => {
1171
+ if (closed)
1172
+ return Effect7.void;
1173
+ closed = true;
1174
+ return Effect7.andThen(Effect7.logDebug("Mock: closing transport"), SubscriptionRef3.set(connectionState, ConnectionState.Disconnected()));
1175
+ });
1176
+ yield* Effect7.addFinalizer(() => closeTransport);
1177
+ const reconnectOnce = Effect7.andThen(ensureOpen, Effect7.andThen(Effect7.logDebug("Mock: reconnecting"), Effect7.suspend(() => {
466
1178
  const injected = options.reconnectFault?.();
467
- return injected ? Effect4.fail(injected) : Effect4.void;
468
- }));
1179
+ return injected ? Effect7.fail(injected) : Effect7.void;
1180
+ })));
469
1181
  const report = (error) => {
470
- if (!supervised || !supervised.triggers(error))
471
- return Effect4.void;
1182
+ if (closed || !resolvedReconnect.triggers(error))
1183
+ return Effect7.void;
1184
+ if (!supervised) {
1185
+ return SubscriptionRef3.update(connectionState, (current) => ConnectionState.$is("Connected")(current) ? ConnectionState.Down({ cause: error }) : current);
1186
+ }
472
1187
  return claimReconnect(reconnectOnce, connectionState, supervised, serviceScope);
473
1188
  };
474
- const guard = Effect4.andThen(supervised ? guardCircuit(connectionState) : Effect4.void, Effect4.suspend(() => {
1189
+ const guard = Effect7.andThen(ensureOpen, Effect7.andThen(supervised ? guardCircuit(connectionState) : Effect7.void, Effect7.suspend(() => {
475
1190
  const injected = options.fault?.();
476
- return injected ? Effect4.fail(injected) : Effect4.void;
477
- }));
1191
+ return injected ? Effect7.fail(injected) : Effect7.void;
1192
+ })));
1193
+ const onSuccess = supervised ? undefined : SubscriptionRef3.update(connectionState, (current) => ConnectionState.$is("Down")(current) ? ConnectionState.Connected() : current);
1194
+ const touchedUnits = new Set;
1195
+ const makeOperations = Effect7.fnUntraced(function* (unitId) {
1196
+ yield* ensureOpen;
1197
+ const state = deviceStates.get(unitId);
1198
+ if (!state) {
1199
+ return yield* new ModbusInvalidArgumentError({
1200
+ cause: new Error(`Device with unitId ${unitId} not found in mock configuration`),
1201
+ message: `Device with unitId ${unitId} not found in mock configuration`
1202
+ });
1203
+ }
1204
+ touchedUnits.add(unitId);
1205
+ return makeMockModbusClient(state, unitId);
1206
+ });
1207
+ const makeClient = Effect7.fnUntraced(function* (unitId, clientOptions) {
1208
+ const operations = yield* makeOperations(unitId);
1209
+ return withResilience(operations, {
1210
+ guard,
1211
+ onSuccess,
1212
+ report,
1213
+ policy: clientOptions?.retry ?? options.retry
1214
+ });
1215
+ });
1216
+ const batching = createBatchingRegistry({
1217
+ withClient: makeClient,
1218
+ connectionState,
1219
+ scope: serviceScope
1220
+ });
1221
+ const withClient = Effect7.fnUntraced(function* (unitId, clientOptions) {
1222
+ const operations = yield* makeOperations(unitId);
1223
+ return withResilience(batching.guardRawWrites(unitId, operations), {
1224
+ guard,
1225
+ onSuccess,
1226
+ report,
1227
+ policy: clientOptions?.retry ?? options.retry
1228
+ });
1229
+ });
478
1230
  return {
479
1231
  connectionState,
480
- withClient: Effect4.fnUntraced(function* (unitId, clientOptions) {
481
- const state = deviceStates.get(unitId);
482
- if (!state) {
483
- return yield* new ModbusInvalidArgumentError({
484
- cause: new Error(`Device with unitId ${unitId} not found in mock configuration`),
485
- message: `Device with unitId ${unitId} not found in mock configuration`
486
- });
487
- }
488
- return withResilience(makeMockModbusClient(state, unitId), {
489
- guard,
490
- report,
491
- policy: clientOptions?.retry ?? options.retry
492
- });
1232
+ withClient,
1233
+ withBatchingClient: (unitId, clientOptions) => Effect7.andThen(ensureOpen, batching.withBatchingClient(unitId, clientOptions)),
1234
+ batchingClient: (unitId) => Effect7.andThen(ensureOpen, batching.batchingClient(unitId)),
1235
+ get touchedUnits() {
1236
+ return new Set(touchedUnits);
1237
+ },
1238
+ setRequestTimeout: (_timeoutMs) => ensureOpen,
1239
+ clearRequestTimeout: () => ensureOpen,
1240
+ reconnect: () => Effect7.andThen(reconnectOnce, SubscriptionRef3.set(connectionState, ConnectionState.Connected())),
1241
+ close: () => Effect7.gen(function* () {
1242
+ if (closed)
1243
+ return;
1244
+ const scope = yield* Effect7.scope;
1245
+ yield* Scope2.close(scope, Exit3.void).pipe(Effect7.onExit(() => closeTransport));
493
1246
  }),
494
- setRequestTimeout: (_timeoutMs) => Effect4.void,
495
- clearRequestTimeout: () => Effect4.void,
496
- reconnect: () => Effect4.andThen(reconnectOnce, SubscriptionRef2.set(connectionState, ConnectionState.Connected())),
497
- close: () => Effect4.andThen(Effect4.logDebug("Mock: closing transport"), SubscriptionRef2.set(connectionState, ConnectionState.Disconnected())),
498
1247
  hasPendingRequests: () => false
499
1248
  };
500
1249
  });
501
1250
  };
502
1251
 
503
1252
  // src/shared-transport.ts
504
- import { Deferred, Effect as Effect5, Exit, Option, Ref, Scope, SubscriptionRef as SubscriptionRef3 } from "effect";
1253
+ import { Deferred as Deferred4, Effect as Effect8, Exit as Exit4, Option, Ref, Scope as Scope3, SubscriptionRef as SubscriptionRef4 } from "effect";
505
1254
  var makeInFlight = () => Ref.make(Option.none());
506
- var singleFlight = (inFlight, work) => Effect5.uninterruptibleMask((restore) => Effect5.gen(function* () {
507
- const fresh = yield* Deferred.make();
1255
+ var singleFlight = (inFlight, work) => Effect8.uninterruptibleMask((restore) => Effect8.gen(function* () {
1256
+ const fresh = yield* Deferred4.make();
508
1257
  const [deferred, isLeader] = yield* Ref.modify(inFlight, (current) => Option.match(current, {
509
1258
  onSome: (existing) => [[existing, false], current],
510
1259
  onNone: () => [[fresh, true], Option.some(fresh)]
511
1260
  }));
512
1261
  if (isLeader) {
513
- yield* Effect5.forkDetach(Effect5.interruptible(work).pipe(Effect5.onExit((exit) => Effect5.andThen(Ref.set(inFlight, Option.none()), Deferred.done(deferred, exit)))));
1262
+ yield* Effect8.forkDetach(Effect8.interruptible(work).pipe(Effect8.onExit((exit) => Effect8.andThen(Ref.set(inFlight, Option.none()), Deferred4.done(deferred, exit)))));
514
1263
  }
515
- return yield* restore(Deferred.await(deferred));
1264
+ return yield* restore(Deferred4.await(deferred));
516
1265
  }));
517
- function makeTransportScoped(transportKey, openMethod, serviceName, config) {
518
- return Effect5.fnUntraced(function* (options) {
519
- const { retry: transportRetry, reconnect: reconnectOptions, ...rest } = options;
520
- const openOptions = rest;
521
- const mod = config?.moduleSpecifier === "modbus-rs/web" ? yield* Effect5.promise(() => import("modbus-rs/web")) : yield* Effect5.promise(() => import("modbus-rs"));
522
- const TC = mod[transportKey];
1266
+ var loadTransportConstructor = async (transportKey, moduleSpecifier) => {
1267
+ if (moduleSpecifier === "modbus-rs/web") {
1268
+ const mod = await import("modbus-rs/web");
1269
+ switch (transportKey) {
1270
+ case "WasmAsciiTransport":
1271
+ return mod.WasmAsciiTransport;
1272
+ case "WasmRtuTransport":
1273
+ return mod.WasmRtuTransport;
1274
+ case "WasmWsTransport":
1275
+ return mod.WasmWsTransport;
1276
+ default:
1277
+ throw new Error(`${transportKey} is not exported by modbus-rs/web`);
1278
+ }
1279
+ }
1280
+ const mod = await import("modbus-rs");
1281
+ switch (transportKey) {
1282
+ case "AsyncAsciiTransport":
1283
+ return mod.AsyncAsciiTransport;
1284
+ case "AsyncRtuTransport":
1285
+ return mod.AsyncRtuTransport;
1286
+ case "AsyncTcpTransport":
1287
+ return mod.AsyncTcpTransport;
1288
+ default:
1289
+ throw new Error(`${transportKey} is not exported by modbus-rs`);
1290
+ }
1291
+ };
1292
+ function createTransportScoped(transportKey, openMethod, serviceName, config) {
1293
+ return Effect8.fnUntraced(function* (options) {
1294
+ const { retry: transportRetry, reconnect: reconnectOptions } = options;
1295
+ const openOptions = { ...options };
1296
+ Reflect.deleteProperty(openOptions, "retry");
1297
+ Reflect.deleteProperty(openOptions, "reconnect");
1298
+ const TC = yield* Effect8.promise(() => loadTransportConstructor(transportKey, config?.moduleSpecifier ?? "modbus-rs"));
523
1299
  let transport = null;
524
1300
  const opening = yield* makeInFlight();
525
1301
  const reconnecting = yield* makeInFlight();
526
- const connectionState = yield* SubscriptionRef3.make(ConnectionState.Disconnected());
527
- const supervised = reconnectOptions ? resolveReconnect(reconnectOptions) : null;
528
- const serviceScope = yield* Effect5.scope;
1302
+ const connectionState = yield* SubscriptionRef4.make(ConnectionState.Disconnected());
1303
+ const resolvedReconnect = resolveReconnect(reconnectOptions ?? {});
1304
+ const supervised = reconnectOptions ? resolvedReconnect : null;
1305
+ const serviceScope = yield* Effect8.scope;
529
1306
  const clientSet = new Map;
530
1307
  let closed = false;
531
1308
  const transportClosed = () => new ModbusNotConnectedError({
532
1309
  cause: new Error("Transport has been closed"),
533
1310
  message: "Transport has been closed"
534
1311
  });
535
- const closeOrphan = (t) => Effect5.forkDetach(Effect5.ignore(Effect5.tryPromise(() => t.close())));
536
- const openTransport = Effect5.tryPromise({
1312
+ const closeOrphan = (t) => Effect8.forkDetach(Effect8.ignore(Effect8.tryPromise(() => t.close())));
1313
+ const openTransport = Effect8.tryPromise({
537
1314
  try: () => openMethod(TC, openOptions),
538
- catch: (error) => toModbusError(error)
539
- }).pipe(Effect5.tap((t) => closed ? closeOrphan(t) : Effect5.andThen(Effect5.sync(() => {
1315
+ catch: (cause) => toModbusError(cause instanceof Error ? cause : new Error(String(cause)))
1316
+ }).pipe(Effect8.tap((t) => closed ? closeOrphan(t) : Effect8.andThen(Effect8.sync(() => {
540
1317
  transport = t;
541
- }), SubscriptionRef3.set(connectionState, ConnectionState.Connected()))));
542
- const ensureOpen = Effect5.fnUntraced(function* () {
1318
+ }), SubscriptionRef4.set(connectionState, ConnectionState.Connected()))));
1319
+ const ensureOpen = Effect8.fnUntraced(function* () {
543
1320
  if (closed)
544
1321
  return yield* transportClosed();
545
1322
  if (transport)
@@ -549,52 +1326,77 @@ function makeTransportScoped(transportKey, openMethod, serviceName, config) {
549
1326
  return yield* transportClosed();
550
1327
  return t;
551
1328
  });
552
- yield* Effect5.addFinalizer(() => {
1329
+ const closeTransport = Effect8.suspend(() => {
553
1330
  if (closed)
554
- return Effect5.void;
1331
+ return Effect8.void;
555
1332
  closed = true;
556
1333
  const t = transport;
557
1334
  if (!t)
558
- return SubscriptionRef3.set(connectionState, ConnectionState.Disconnected());
559
- return Effect5.andThen(Effect5.logDebug(`Closing ${serviceName}`), Effect5.andThen(Effect5.promise(() => t.close()), SubscriptionRef3.set(connectionState, ConnectionState.Disconnected())));
1335
+ return SubscriptionRef4.set(connectionState, ConnectionState.Disconnected());
1336
+ return Effect8.andThen(Effect8.logDebug(`Closing ${serviceName}`), Effect8.andThen(Effect8.tryPromise({
1337
+ try: () => t.close(),
1338
+ catch: (cause) => toModbusError(cause instanceof Error ? cause : new Error(String(cause)))
1339
+ }), SubscriptionRef4.set(connectionState, ConnectionState.Disconnected())));
560
1340
  });
1341
+ yield* Effect8.addFinalizer(() => Effect8.orDie(closeTransport));
561
1342
  const notConnectedMsg = "Transport is not connected. Call withClient() first.";
562
- const reconnectOnce = Effect5.suspend(() => {
1343
+ const reconnectOnce = Effect8.suspend(() => {
563
1344
  const t = transport;
564
1345
  if (closed || !t)
565
1346
  return transportClosed();
566
- return singleFlight(reconnecting, Effect5.tryPromise({
1347
+ return singleFlight(reconnecting, Effect8.tryPromise({
567
1348
  try: () => t.reconnect(),
568
- catch: (error) => toModbusError(error)
569
- }).pipe(Effect5.tap(() => closed ? closeOrphan(t) : Effect5.void)));
1349
+ catch: (cause) => toModbusError(cause instanceof Error ? cause : new Error(String(cause)))
1350
+ }).pipe(Effect8.tap(() => closed ? closeOrphan(t) : Effect8.void)));
570
1351
  });
571
1352
  const report = (error) => {
572
- if (!supervised || closed || !supervised.triggers(error))
573
- return Effect5.void;
574
- return claimReconnect(reconnectOnce, connectionState, supervised, serviceScope, Effect5.logDebug(`${serviceName}: reconnecting after ${error.message}`));
1353
+ if (closed || !resolvedReconnect.triggers(error))
1354
+ return Effect8.void;
1355
+ if (!supervised) {
1356
+ return SubscriptionRef4.update(connectionState, (current) => ConnectionState.$is("Connected")(current) ? ConnectionState.Down({ cause: error }) : current);
1357
+ }
1358
+ return claimReconnect(reconnectOnce, connectionState, supervised, serviceScope, Effect8.logDebug(`${serviceName}: reconnecting after ${error.message}`));
575
1359
  };
576
1360
  const resilience = {
577
- guard: supervised ? guardCircuit(connectionState) : Effect5.void,
1361
+ guard: supervised ? guardCircuit(connectionState) : Effect8.void,
1362
+ onSuccess: supervised ? undefined : SubscriptionRef4.update(connectionState, (current) => ConnectionState.$is("Down")(current) ? ConnectionState.Connected() : current),
578
1363
  report
579
1364
  };
1365
+ const makeOperations = Effect8.fnUntraced(function* (unitId) {
1366
+ const t = yield* ensureOpen();
1367
+ let client = clientSet.get(unitId);
1368
+ if (!client) {
1369
+ client = yield* Effect8.try({
1370
+ try: () => t.createClient({ unitId }),
1371
+ catch: (cause) => toModbusError(cause instanceof Error ? cause : new Error(String(cause)))
1372
+ });
1373
+ clientSet.set(unitId, client);
1374
+ }
1375
+ return createEffectModbusClient(client);
1376
+ });
1377
+ const makeClient = Effect8.fnUntraced(function* (unitId, clientOptions) {
1378
+ const operations = yield* makeOperations(unitId);
1379
+ return withResilience(operations, {
1380
+ ...resilience,
1381
+ policy: clientOptions?.retry ?? transportRetry
1382
+ });
1383
+ });
1384
+ const batching = createBatchingRegistry({
1385
+ withClient: makeClient,
1386
+ connectionState,
1387
+ scope: serviceScope
1388
+ });
1389
+ const withClient = Effect8.fnUntraced(function* (unitId, clientOptions) {
1390
+ const operations = yield* makeOperations(unitId);
1391
+ return withResilience(batching.guardRawWrites(unitId, operations), {
1392
+ ...resilience,
1393
+ policy: clientOptions?.retry ?? transportRetry
1394
+ });
1395
+ });
580
1396
  return {
581
1397
  connectionState,
582
- withClient: Effect5.fnUntraced(function* (unitId, clientOptions) {
583
- const t = yield* ensureOpen();
584
- let client = clientSet.get(unitId);
585
- if (!client) {
586
- client = yield* Effect5.try({
587
- try: () => t.createClient({ unitId }),
588
- catch: (error) => toModbusError(error)
589
- });
590
- clientSet.set(unitId, client);
591
- }
592
- return withResilience(makeEffectModbusClient(client), {
593
- ...resilience,
594
- policy: clientOptions?.retry ?? transportRetry
595
- });
596
- }),
597
- setRequestTimeout: Effect5.fnUntraced(function* (timeoutMs) {
1398
+ withClient,
1399
+ setRequestTimeout: Effect8.fnUntraced(function* (timeoutMs) {
598
1400
  const t = transport;
599
1401
  if (!t || closed) {
600
1402
  return yield* new ModbusNotConnectedError({
@@ -604,7 +1406,7 @@ function makeTransportScoped(transportKey, openMethod, serviceName, config) {
604
1406
  }
605
1407
  t.setRequestTimeout(timeoutMs);
606
1408
  }),
607
- clearRequestTimeout: Effect5.fnUntraced(function* () {
1409
+ clearRequestTimeout: Effect8.fnUntraced(function* () {
608
1410
  const t = transport;
609
1411
  if (!t || closed) {
610
1412
  return yield* new ModbusNotConnectedError({
@@ -614,7 +1416,7 @@ function makeTransportScoped(transportKey, openMethod, serviceName, config) {
614
1416
  }
615
1417
  t.clearRequestTimeout();
616
1418
  }),
617
- reconnect: Effect5.fnUntraced(function* () {
1419
+ reconnect: Effect8.fnUntraced(function* () {
618
1420
  if (closed)
619
1421
  return yield* transportClosed();
620
1422
  const t = transport;
@@ -622,29 +1424,25 @@ function makeTransportScoped(transportKey, openMethod, serviceName, config) {
622
1424
  yield* ensureOpen();
623
1425
  return;
624
1426
  }
625
- yield* singleFlight(reconnecting, Effect5.tryPromise({
1427
+ yield* singleFlight(reconnecting, Effect8.tryPromise({
626
1428
  try: () => t.reconnect(),
627
- catch: (error) => toModbusError(error)
628
- }).pipe(Effect5.tap(() => closed ? closeOrphan(t) : Effect5.void)));
1429
+ catch: (cause) => toModbusError(cause instanceof Error ? cause : new Error(String(cause)))
1430
+ }).pipe(Effect8.tap(() => closed ? closeOrphan(t) : Effect8.void)));
629
1431
  if (closed)
630
1432
  return yield* transportClosed();
631
- yield* SubscriptionRef3.set(connectionState, ConnectionState.Connected());
1433
+ yield* SubscriptionRef4.set(connectionState, ConnectionState.Connected());
632
1434
  }),
633
- close: Effect5.fnUntraced(function* () {
1435
+ close: Effect8.fnUntraced(function* () {
634
1436
  if (closed)
635
1437
  return;
636
- closed = true;
637
- yield* SubscriptionRef3.set(connectionState, ConnectionState.Disconnected());
638
- const t = transport;
639
- if (t) {
640
- yield* Effect5.tryPromise({
641
- try: () => t.close(),
642
- catch: (error) => toModbusError(error)
643
- });
644
- }
645
- const scope = yield* Effect5.scope;
646
- yield* Scope.close(scope, Exit.void);
1438
+ const scope = yield* Effect8.scope;
1439
+ yield* Scope3.close(scope, Exit4.void).pipe(Effect8.onExit(() => closeTransport));
647
1440
  }),
1441
+ withBatchingClient: batching.withBatchingClient,
1442
+ batchingClient: batching.batchingClient,
1443
+ get touchedUnits() {
1444
+ return new Set(clientSet.keys());
1445
+ },
648
1446
  hasPendingRequests: () => {
649
1447
  if (closed)
650
1448
  return false;
@@ -659,10 +1457,12 @@ function makeTransportScoped(transportKey, openMethod, serviceName, config) {
659
1457
 
660
1458
  // src/AsciiTransportService.ts
661
1459
  class AsciiTransportService extends Context.Service()("AsciiTransportService") {
662
- static makeScoped = makeTransportScoped("AsyncAsciiTransport", (TC, options) => TC.open(options), "AsciiTransportService");
1460
+ static makeScoped = createTransportScoped("AsyncAsciiTransport", (transportConstructor, options) => {
1461
+ return transportConstructor.open(options);
1462
+ }, "AsciiTransportService");
663
1463
  static make = (options) => Layer.effect(AsciiTransportService, AsciiTransportService.makeScoped(options));
664
1464
  static makeMockTransport = (devices) => {
665
- const factory = makeMockTransport(devices);
1465
+ const factory = createMockTransport(devices);
666
1466
  return (options) => Layer.effect(AsciiTransportService, factory(options));
667
1467
  };
668
1468
  }
@@ -672,10 +1472,12 @@ import { Context as Context3, Layer as Layer3 } from "effect";
672
1472
  // src/RtuTransportService.ts
673
1473
  import { Context as Context2, Layer as Layer2 } from "effect";
674
1474
  class RtuTransportService extends Context2.Service()("RtuTransportService") {
675
- static makeScoped = makeTransportScoped("AsyncRtuTransport", (TC, options) => TC.open(options), "RtuTransportService");
1475
+ static makeScoped = createTransportScoped("AsyncRtuTransport", (transportConstructor, options) => {
1476
+ return transportConstructor.open(options);
1477
+ }, "RtuTransportService");
676
1478
  static make = (options) => Layer2.effect(RtuTransportService, RtuTransportService.makeScoped(options));
677
1479
  static makeMockTransport = (devices) => {
678
- const factory = makeMockTransport(devices);
1480
+ const factory = createMockTransport(devices);
679
1481
  return (options) => Layer2.effect(RtuTransportService, factory(options));
680
1482
  };
681
1483
  }
@@ -689,17 +1491,19 @@ class SerialTransportService extends Context3.Service()("SerialTransportService"
689
1491
  return Layer3.flatMap(RtuTransportService.make(options), (context) => Layer3.succeed(SerialTransportService, Context3.get(context, RtuTransportService)));
690
1492
  }
691
1493
  static makeMockTransport = (devices) => {
692
- const factory = makeMockTransport(devices);
1494
+ const factory = createMockTransport(devices);
693
1495
  return (options) => Layer3.effect(SerialTransportService, factory(options));
694
1496
  };
695
1497
  }
696
1498
  // src/TcpTransportService.ts
697
1499
  import { Context as Context4, Layer as Layer4 } from "effect";
698
1500
  class TcpTransportService extends Context4.Service()("TcpTransportService") {
699
- static makeScoped = makeTransportScoped("AsyncTcpTransport", (TC, options) => TC.connect(options), "TcpTransportService");
1501
+ static makeScoped = createTransportScoped("AsyncTcpTransport", (transportConstructor, options) => {
1502
+ return transportConstructor.connect(options);
1503
+ }, "TcpTransportService");
700
1504
  static make = (options) => Layer4.effect(TcpTransportService, TcpTransportService.makeScoped(options));
701
1505
  static makeMockTransport = (devices) => {
702
- const factory = makeMockTransport(devices);
1506
+ const factory = createMockTransport(devices);
703
1507
  return (options) => Layer4.effect(TcpTransportService, factory(options));
704
1508
  };
705
1509
  }
@@ -709,24 +1513,24 @@ var serialRtuServerLayer = (options, handlers) => Layer5.effectDiscard(Effect9.g
709
1513
  const { AsyncSerialModbusServer } = yield* Effect9.promise(() => import("modbus-rs"));
710
1514
  const server = yield* Effect9.tryPromise({
711
1515
  try: () => AsyncSerialModbusServer.bindRtu(options, handlers),
712
- catch: (error) => toModbusError(error)
1516
+ catch: (error) => toModbusError(error instanceof Error ? error : new Error(String(error)))
713
1517
  });
714
1518
  yield* Effect9.logDebug(`Serial RTU server bound to ${options.portPath}`);
715
1519
  yield* Effect9.addFinalizer(() => Effect9.logDebug("Serial RTU server shutting down").pipe(Effect9.andThen(Effect9.tryPromise({
716
1520
  try: () => server.shutdown(),
717
- catch: (error) => toModbusError(error)
1521
+ catch: (error) => toModbusError(error instanceof Error ? error : new Error(String(error)))
718
1522
  })), Effect9.catch(() => Effect9.void)));
719
1523
  }));
720
1524
  var serialAsciiServerLayer = (options, handlers) => Layer5.effectDiscard(Effect9.gen(function* () {
721
1525
  const { AsyncSerialModbusServer } = yield* Effect9.promise(() => import("modbus-rs"));
722
1526
  const server = yield* Effect9.tryPromise({
723
1527
  try: () => AsyncSerialModbusServer.bindAscii(options, handlers),
724
- catch: (error) => toModbusError(error)
1528
+ catch: (error) => toModbusError(error instanceof Error ? error : new Error(String(error)))
725
1529
  });
726
1530
  yield* Effect9.logDebug(`Serial ASCII server bound to ${options.portPath}`);
727
1531
  yield* Effect9.addFinalizer(() => Effect9.logDebug("Serial ASCII server shutting down").pipe(Effect9.andThen(Effect9.tryPromise({
728
1532
  try: () => server.shutdown(),
729
- catch: (error) => toModbusError(error)
1533
+ catch: (error) => toModbusError(error instanceof Error ? error : new Error(String(error)))
730
1534
  })), Effect9.catch(() => Effect9.void)));
731
1535
  }));
732
1536
  // src/TcpModbusServerService.ts
@@ -735,12 +1539,12 @@ var tcpServerLayer = (options, handlers) => Layer6.effectDiscard(Effect10.gen(fu
735
1539
  const { AsyncTcpModbusServer } = yield* Effect10.promise(() => import("modbus-rs"));
736
1540
  const server = yield* Effect10.tryPromise({
737
1541
  try: () => AsyncTcpModbusServer.bind(options, handlers),
738
- catch: (error) => toModbusError(error)
1542
+ catch: (error) => toModbusError(error instanceof Error ? error : new Error(String(error)))
739
1543
  });
740
1544
  yield* Effect10.logDebug(`TCP server bound to ${options.host}:${options.port}`);
741
1545
  yield* Effect10.addFinalizer(() => Effect10.logDebug("TCP server shutting down").pipe(Effect10.andThen(Effect10.tryPromise({
742
1546
  try: () => server.shutdown(),
743
- catch: (error) => toModbusError(error)
1547
+ catch: (error) => toModbusError(error instanceof Error ? error : new Error(String(error)))
744
1548
  })), Effect10.catch(() => Effect10.void)));
745
1549
  }));
746
1550
  // src/TcpGatewayService.ts
@@ -749,41 +1553,47 @@ var tcpGatewayLayer = (options, gatewayConfig) => Layer7.effectDiscard(Effect11.
749
1553
  const { AsyncTcpGateway } = yield* Effect11.promise(() => import("modbus-rs"));
750
1554
  const gateway = yield* Effect11.tryPromise({
751
1555
  try: () => AsyncTcpGateway.bind(options, gatewayConfig),
752
- catch: (error) => toModbusError(error)
1556
+ catch: (error) => toModbusError(error instanceof Error ? error : new Error(String(error)))
753
1557
  });
754
1558
  yield* Effect11.logDebug(`TCP gateway bound to ${options.host}:${options.port}`);
755
1559
  yield* Effect11.addFinalizer(() => Effect11.logDebug("TCP gateway shutting down").pipe(Effect11.andThen(Effect11.tryPromise({
756
1560
  try: () => gateway.shutdown(),
757
- catch: (error) => toModbusError(error)
1561
+ catch: (error) => toModbusError(error instanceof Error ? error : new Error(String(error)))
758
1562
  })), Effect11.catch(() => Effect11.void)));
759
1563
  }));
760
1564
  // src/WasmWsTransportService.ts
761
1565
  import { Context as Context5, Layer as Layer8 } from "effect";
762
1566
  class WasmWsTransportService extends Context5.Service()("WasmWsTransportService") {
763
- static makeScoped = makeTransportScoped("WasmWsTransport", (TC, options) => TC.connect(options), "WasmWsTransportService", { moduleSpecifier: "modbus-rs/web" });
1567
+ static makeScoped = createTransportScoped("WasmWsTransport", (transportConstructor, options) => {
1568
+ return transportConstructor.connect(options);
1569
+ }, "WasmWsTransportService", { moduleSpecifier: "modbus-rs/web" });
764
1570
  static make = (options) => Layer8.effect(WasmWsTransportService, WasmWsTransportService.makeScoped(options));
765
1571
  static makeMockTransport = (devices) => {
766
- const factory = makeMockTransport(devices);
1572
+ const factory = createMockTransport(devices);
767
1573
  return (options) => Layer8.effect(WasmWsTransportService, factory(options));
768
1574
  };
769
1575
  }
770
1576
  // src/WasmRtuTransportService.ts
771
1577
  import { Context as Context6, Layer as Layer9 } from "effect";
772
1578
  class WasmRtuTransportService extends Context6.Service()("WasmRtuTransportService") {
773
- static makeScoped = makeTransportScoped("WasmRtuTransport", (TC, { port, ...rest }) => TC.open(port, rest), "WasmRtuTransportService", { moduleSpecifier: "modbus-rs/web" });
1579
+ static makeScoped = createTransportScoped("WasmRtuTransport", (transportConstructor, { port, ...rest }) => {
1580
+ return transportConstructor.open(port, rest);
1581
+ }, "WasmRtuTransportService", { moduleSpecifier: "modbus-rs/web" });
774
1582
  static make = (options) => Layer9.effect(WasmRtuTransportService, WasmRtuTransportService.makeScoped(options));
775
1583
  static makeMockTransport = (devices) => {
776
- const factory = makeMockTransport(devices);
1584
+ const factory = createMockTransport(devices);
777
1585
  return (options) => Layer9.effect(WasmRtuTransportService, factory(options));
778
1586
  };
779
1587
  }
780
1588
  // src/WasmAsciiTransportService.ts
781
1589
  import { Context as Context7, Layer as Layer10 } from "effect";
782
1590
  class WasmAsciiTransportService extends Context7.Service()("WasmAsciiTransportService") {
783
- static makeScoped = makeTransportScoped("WasmAsciiTransport", (TC, { port, ...rest }) => TC.open(port, rest), "WasmAsciiTransportService", { moduleSpecifier: "modbus-rs/web" });
1591
+ static makeScoped = createTransportScoped("WasmAsciiTransport", (transportConstructor, { port, ...rest }) => {
1592
+ return transportConstructor.open(port, rest);
1593
+ }, "WasmAsciiTransportService", { moduleSpecifier: "modbus-rs/web" });
784
1594
  static make = (options) => Layer10.effect(WasmAsciiTransportService, WasmAsciiTransportService.makeScoped(options));
785
1595
  static makeMockTransport = (devices) => {
786
- const factory = makeMockTransport(devices);
1596
+ const factory = createMockTransport(devices);
787
1597
  return (options) => Layer10.effect(WasmAsciiTransportService, factory(options));
788
1598
  };
789
1599
  }
@@ -797,74 +1607,76 @@ class WasmSerialTransportService extends Context8.Service()("WasmSerialTransport
797
1607
  return Layer11.flatMap(WasmRtuTransportService.make(options), (context) => Layer11.succeed(WasmSerialTransportService, Context8.get(context, WasmRtuTransportService)));
798
1608
  }
799
1609
  static makeMockTransport = (devices) => {
800
- const factory = makeMockTransport(devices);
1610
+ const factory = createMockTransport(devices);
801
1611
  return (options) => Layer11.effect(WasmSerialTransportService, factory(options));
802
1612
  };
803
1613
  }
804
1614
  // src/WasmSerialPort.ts
805
- import { Effect as Effect15 } from "effect";
806
- var requestSerialPort = () => Effect15.tryPromise({
1615
+ import { Effect as Effect12 } from "effect";
1616
+ var requestSerialPort = () => Effect12.tryPromise({
807
1617
  try: async () => {
808
1618
  const mod = await import("modbus-rs/web");
809
1619
  return mod.requestSerialPort();
810
1620
  },
811
- catch: (error) => toModbusError(error)
1621
+ catch: (error) => toModbusError(error instanceof Error ? error : new Error(String(error)))
812
1622
  });
813
1623
  // src/WasmTcpServerService.ts
814
- import { Effect as Effect16, Layer as Layer12 } from "effect";
815
- var wasmWsServerLayer = (options, handlers) => Layer12.effectDiscard(Effect16.gen(function* () {
816
- const { WasmWsModbusServer } = yield* Effect16.promise(() => import("modbus-rs/web"));
817
- const server = yield* Effect16.tryPromise({
1624
+ import { Effect as Effect13, Layer as Layer12 } from "effect";
1625
+ var wasmWsServerLayer = (options, handlers) => Layer12.effectDiscard(Effect13.gen(function* () {
1626
+ const { WasmWsModbusServer } = yield* Effect13.promise(() => import("modbus-rs/web"));
1627
+ const server = yield* Effect13.tryPromise({
818
1628
  try: () => WasmWsModbusServer.bind(options, handlers),
819
- catch: (error) => toModbusError(error)
1629
+ catch: (error) => toModbusError(error instanceof Error ? error : new Error(String(error)))
820
1630
  });
821
- yield* Effect16.logDebug(`WASM WS server bound to ${options.wsUrl}`);
822
- yield* Effect16.forkScoped(Effect16.tryPromise({
1631
+ yield* Effect13.logDebug(`WASM WS server bound to ${options.wsUrl}`);
1632
+ yield* Effect13.forkScoped(Effect13.tryPromise({
823
1633
  try: () => server.serve(),
824
- catch: (error) => toModbusError(error)
825
- }).pipe(Effect16.catch((error) => Effect16.logError("WASM WS server loop ended", error))));
826
- yield* Effect16.addFinalizer(() => Effect16.logDebug("WASM WS server shutting down").pipe(Effect16.andThen(Effect16.tryPromise({
1634
+ catch: (error) => toModbusError(error instanceof Error ? error : new Error(String(error)))
1635
+ }).pipe(Effect13.catch((error) => Effect13.logError("WASM WS server loop ended", error))));
1636
+ yield* Effect13.addFinalizer(() => Effect13.logDebug("WASM WS server shutting down").pipe(Effect13.andThen(Effect13.tryPromise({
827
1637
  try: () => server.shutdown(),
828
- catch: (error) => toModbusError(error)
829
- })), Effect16.catch(() => Effect16.void)));
1638
+ catch: (error) => toModbusError(error instanceof Error ? error : new Error(String(error)))
1639
+ })), Effect13.catch(() => Effect13.void)));
830
1640
  }));
831
1641
  // src/WasmSerialModbusServerService.ts
832
- import { Effect as Effect17, Layer as Layer13 } from "effect";
833
- var wasmSerialRtuServerLayer = (options, handlers) => Layer13.effectDiscard(Effect17.gen(function* () {
834
- const { WasmSerialModbusServer } = yield* Effect17.promise(() => import("modbus-rs/web"));
835
- const server = yield* Effect17.tryPromise({
1642
+ import { Effect as Effect14, Layer as Layer13 } from "effect";
1643
+ var wasmSerialRtuServerLayer = (options, handlers) => Layer13.effectDiscard(Effect14.gen(function* () {
1644
+ const { WasmSerialModbusServer } = yield* Effect14.promise(() => import("modbus-rs/web"));
1645
+ const server = yield* Effect14.tryPromise({
836
1646
  try: () => WasmSerialModbusServer.bindRtu(options, handlers),
837
- catch: (error) => toModbusError(error)
1647
+ catch: (error) => toModbusError(error instanceof Error ? error : new Error(String(error)))
838
1648
  });
839
- yield* Effect17.logDebug("WASM serial RTU server bound");
840
- yield* Effect17.forkScoped(Effect17.tryPromise({
1649
+ yield* Effect14.logDebug("WASM serial RTU server bound");
1650
+ yield* Effect14.forkScoped(Effect14.tryPromise({
841
1651
  try: () => server.serve(),
842
- catch: (error) => toModbusError(error)
843
- }).pipe(Effect17.catch((error) => Effect17.logError("WASM serial RTU server loop ended", error))));
844
- yield* Effect17.addFinalizer(() => Effect17.logDebug("WASM serial RTU server shutting down").pipe(Effect17.andThen(Effect17.tryPromise({
1652
+ catch: (error) => toModbusError(error instanceof Error ? error : new Error(String(error)))
1653
+ }).pipe(Effect14.catch((error) => Effect14.logError("WASM serial RTU server loop ended", error))));
1654
+ yield* Effect14.addFinalizer(() => Effect14.logDebug("WASM serial RTU server shutting down").pipe(Effect14.andThen(Effect14.tryPromise({
845
1655
  try: () => server.shutdown(),
846
- catch: (error) => toModbusError(error)
847
- })), Effect17.catch(() => Effect17.void)));
1656
+ catch: (error) => toModbusError(error instanceof Error ? error : new Error(String(error)))
1657
+ })), Effect14.catch(() => Effect14.void)));
848
1658
  }));
849
- var wasmSerialAsciiServerLayer = (options, handlers) => Layer13.effectDiscard(Effect17.gen(function* () {
850
- const { WasmSerialModbusServer } = yield* Effect17.promise(() => import("modbus-rs/web"));
851
- const server = yield* Effect17.tryPromise({
1659
+ var wasmSerialAsciiServerLayer = (options, handlers) => Layer13.effectDiscard(Effect14.gen(function* () {
1660
+ const { WasmSerialModbusServer } = yield* Effect14.promise(() => import("modbus-rs/web"));
1661
+ const server = yield* Effect14.tryPromise({
852
1662
  try: () => WasmSerialModbusServer.bindAscii(options, handlers),
853
- catch: (error) => toModbusError(error)
1663
+ catch: (error) => toModbusError(error instanceof Error ? error : new Error(String(error)))
854
1664
  });
855
- yield* Effect17.logDebug("WASM serial ASCII server bound");
856
- yield* Effect17.forkScoped(Effect17.tryPromise({
1665
+ yield* Effect14.logDebug("WASM serial ASCII server bound");
1666
+ yield* Effect14.forkScoped(Effect14.tryPromise({
857
1667
  try: () => server.serve(),
858
- catch: (error) => toModbusError(error)
859
- }).pipe(Effect17.catch((error) => Effect17.logError("WASM serial ASCII server loop ended", error))));
860
- yield* Effect17.addFinalizer(() => Effect17.logDebug("WASM serial ASCII server shutting down").pipe(Effect17.andThen(Effect17.tryPromise({
1668
+ catch: (error) => toModbusError(error instanceof Error ? error : new Error(String(error)))
1669
+ }).pipe(Effect14.catch((error) => Effect14.logError("WASM serial ASCII server loop ended", error))));
1670
+ yield* Effect14.addFinalizer(() => Effect14.logDebug("WASM serial ASCII server shutting down").pipe(Effect14.andThen(Effect14.tryPromise({
861
1671
  try: () => server.shutdown(),
862
- catch: (error) => toModbusError(error)
863
- })), Effect17.catch(() => Effect17.void)));
1672
+ catch: (error) => toModbusError(error instanceof Error ? error : new Error(String(error)))
1673
+ })), Effect14.catch(() => Effect14.void)));
864
1674
  }));
865
1675
  export {
866
1676
  AsciiTransportService,
867
1677
  ConnectionState,
1678
+ MODBUS_MAX_READ_REGISTERS,
1679
+ MODBUS_MAX_WRITE_REGISTERS,
868
1680
  ModbusCircuitOpenError,
869
1681
  ModbusConnectionClosedError,
870
1682
  ModbusExceptionError,
@@ -881,7 +1693,16 @@ export {
881
1693
  WasmRtuTransportService,
882
1694
  WasmSerialTransportService,
883
1695
  WasmWsTransportService,
884
- makeRetryPolicy,
1696
+ createBatchingRegistry,
1697
+ createReadDebouncer,
1698
+ createRegisterCache,
1699
+ createRetryPolicy,
1700
+ createWriteDebouncer,
1701
+ encodeRegisterValue,
1702
+ makeBatchingClient,
1703
+ mergeSpanAttributes,
1704
+ planReads,
1705
+ planWrites,
885
1706
  requestSerialPort,
886
1707
  retryModbus,
887
1708
  retryableExceptionCodes,