diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
index 7c0e3d3..2efda87 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
@@ -1,4 +1,6 @@
+using System.Buffers.Binary;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
@@ -291,6 +293,15 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
var now = DateTime.UtcNow;
var results = new DataValueSnapshot[fullReferences.Count];
+ // PR F2-d (issue #266): pre-pass coalesces same-letter / same-path PMC byte reads
+ // in this batch into one wire call per group via FocasPmcCoalescer. Each entry in
+ // prefetched maps the original index to the byte slice + status from the range
+ // read; the per-tag loop below short-circuits to the cached slice when present
+ // and falls back to per-tag ReadAsync otherwise. Bit-addressed PMC tags
+ // (R100.3) coalesce on their parent byte (R100); the bit extract still happens
+ // in the existing decode path.
+ var prefetched = await PrefetchPmcRangesAsync(fullReferences, cancellationToken).ConfigureAwait(false);
+
for (var i = 0; i < fullReferences.Count; i++)
{
var reference = fullReferences[i];
@@ -391,6 +402,26 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
await client.SetPathAsync(parsed.PathId, cancellationToken).ConfigureAwait(false);
device.LastSetPath = parsed.PathId;
}
+
+ // Coalesced PMC range fast path (issue #266) — if the prefetch pass already
+ // satisfied this byte range as part of a coalesced wire call, decode from
+ // the cached slice instead of issuing another per-tag ReadAsync.
+ if (prefetched is not null && prefetched.TryGetValue(i, out var slice))
+ {
+ if (slice.Status != FocasStatusMapper.Good)
+ {
+ results[i] = new DataValueSnapshot(null, slice.Status, null, now);
+ if (slice.Status != FocasStatusMapper.Good)
+ _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
+ $"FOCAS status 0x{slice.Status:X8} reading {reference}");
+ continue;
+ }
+ var decoded = DecodePmcSlice(slice.Bytes, def.DataType, parsed.BitIndex);
+ results[i] = new DataValueSnapshot(decoded, FocasStatusMapper.Good, now, now);
+ _health = new DriverHealth(DriverState.Healthy, now, null);
+ continue;
+ }
+
var (value, status) = parsed.Kind == FocasAreaKind.Diagnostic
? await client.ReadDiagnosticAsync(
parsed.Number, parsed.BitIndex ?? 0, def.DataType, cancellationToken).ConfigureAwait(false)
@@ -1054,6 +1085,149 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
return new DataValueSnapshot(value, FocasStatusMapper.Good, now, now);
}
+ ///
+ /// Pre-pass for that groups same-letter / same-path PMC byte
+ /// reads from into coalesced range reads (issue
+ /// #266). Returns a map from the original batch index to the byte slice + status the
+ /// wire returned for that tag's bytes; entries are absent for non-PMC tags + for tags
+ /// whose coalesced read failed at the connect / set-path / parse step (those fall
+ /// back to the existing per-tag dispatch in the main loop). Bit-addressed PMC tags
+ /// (e.g. R100.3) are coalesced on their parent byte; the bit extract is
+ /// deferred to so the slice still satisfies them.
+ ///
+ private async Task?> PrefetchPmcRangesAsync(
+ IReadOnlyList fullReferences, CancellationToken cancellationToken)
+ {
+ // Build per-device PMC request lists. Skip non-PMC tags + tags that don't resolve.
+ Dictionary>? perDevice = null;
+ for (var i = 0; i < fullReferences.Count; i++)
+ {
+ var reference = fullReferences[i];
+ if (_statusNodesByName.ContainsKey(reference) ||
+ _productionNodesByName.ContainsKey(reference) ||
+ _modalNodesByName.ContainsKey(reference) ||
+ _overrideNodesByName.ContainsKey(reference) ||
+ _toolingNodesByName.ContainsKey(reference) ||
+ _offsetNodesByName.ContainsKey(reference) ||
+ _messagesNodesByName.ContainsKey(reference) ||
+ _currentBlockNodesByName.ContainsKey(reference) ||
+ _diagnosticsNodesByName.ContainsKey(reference))
+ continue;
+ if (!_tagsByName.TryGetValue(reference, out var def)) continue;
+ if (!_devices.TryGetValue(def.DeviceHostAddress, out _)) continue;
+ var parsed = FocasAddress.TryParse(def.Address);
+ if (parsed is null) continue;
+ if (parsed.Kind != FocasAreaKind.Pmc) continue;
+ if (string.IsNullOrEmpty(parsed.PmcLetter)) continue;
+ perDevice ??= new(StringComparer.OrdinalIgnoreCase);
+ if (!perDevice.TryGetValue(def.DeviceHostAddress, out var list))
+ perDevice[def.DeviceHostAddress] = list = new();
+ list.Add((i, parsed, def));
+ }
+ if (perDevice is null) return null;
+
+ var result = new Dictionary();
+ foreach (var (host, list) in perDevice)
+ {
+ // Single-tag PMC reads aren't worth coalescing — fall through to the per-tag
+ // path so we don't pay the connect/set-path overhead twice.
+ if (list.Count < 2) continue;
+ var device = _devices[host];
+
+ IFocasClient client;
+ try
+ {
+ client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) { throw; }
+ catch
+ {
+ continue; // per-tag path will surface BadCommunicationError per tag
+ }
+
+ // Build coalescer requests. Skip path-out-of-range tags here so the per-tag
+ // path can map them to BadOutOfRange consistently.
+ var pmcRequests = new List(list.Count);
+ foreach (var (idx, parsed, def) in list)
+ {
+ if (parsed.PathId > 1 && device.PathCount > 0 && parsed.PathId > device.PathCount) continue;
+ pmcRequests.Add(new PmcAddressRequest(
+ parsed.PmcLetter!, parsed.PathId,
+ parsed.Number, FocasPmcCoalescer.ByteWidth(def.DataType), idx));
+ }
+ if (pmcRequests.Count < 2) continue;
+
+ var groups = FocasPmcCoalescer.Plan(pmcRequests);
+ foreach (var group in groups)
+ {
+ if (group.Members.Count < 2) continue; // single-member group — no benefit
+ if (group.PathId != 1 && device.LastSetPath != group.PathId)
+ {
+ try
+ {
+ await client.SetPathAsync(group.PathId, cancellationToken).ConfigureAwait(false);
+ device.LastSetPath = group.PathId;
+ }
+ catch (OperationCanceledException) { throw; }
+ catch { continue; }
+ }
+
+ byte[]? buffer; uint status;
+ try
+ {
+ (buffer, status) = await client.ReadPmcRangeAsync(
+ group.Letter, group.PathId, group.StartByte, group.ByteCount, cancellationToken)
+ .ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) { throw; }
+ catch
+ {
+ continue; // per-tag path will surface BadCommunicationError
+ }
+
+ foreach (var member in group.Members)
+ {
+ if (status != FocasStatusMapper.Good || buffer is null)
+ {
+ result[member.OriginalIndex] = new PmcSlice(Array.Empty(), status);
+ continue;
+ }
+ var slice = new byte[member.ByteWidth];
+ if (member.Offset + member.ByteWidth <= buffer.Length)
+ Array.Copy(buffer, member.Offset, slice, 0, member.ByteWidth);
+ result[member.OriginalIndex] = new PmcSlice(slice, FocasStatusMapper.Good);
+ }
+ }
+ }
+ return result.Count == 0 ? null : result;
+ }
+
+ ///
+ /// Decode the bytes of a coalesced PMC slice into the boxed managed value the
+ /// driver returns from . Mirrors the per-byte decode in
+ /// 's ReadPmc + the bit-extract path so the
+ /// coalesced read is observably equivalent to a per-tag read.
+ ///
+ private static object? DecodePmcSlice(byte[] slice, FocasDataType type, int? bitIndex) => type switch
+ {
+ FocasDataType.Bit when bitIndex is int bi =>
+ slice.Length > 0 ? (object)((slice[0] & (1 << bi)) != 0) : null,
+ FocasDataType.Byte =>
+ slice.Length > 0 ? (object)(sbyte)slice[0] : null,
+ FocasDataType.Int16 =>
+ slice.Length >= 2 ? (object)BinaryPrimitives.ReadInt16LittleEndian(slice) : null,
+ FocasDataType.Int32 =>
+ slice.Length >= 4 ? (object)BinaryPrimitives.ReadInt32LittleEndian(slice) : null,
+ FocasDataType.Float32 =>
+ slice.Length >= 4 ? (object)BinaryPrimitives.ReadSingleLittleEndian(slice) : null,
+ FocasDataType.Float64 =>
+ slice.Length >= 8 ? (object)BinaryPrimitives.ReadDoubleLittleEndian(slice) : null,
+ _ => slice.Length > 0 ? (object)slice[0] : null,
+ };
+
+ /// One coalesced PMC slice — bytes the wire returned for one batch member + the status.
+ private readonly record struct PmcSlice(byte[] Bytes, uint Status);
+
///
/// Apply cnc_getfigure-derived decimal scaling to a raw position value.
/// Returns divided by 10^decimalPlaces when the
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs
index 9ff8ae2..5a99cad 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs
@@ -445,6 +445,42 @@ internal sealed class FwlibFocasClient : IFocasClient
return (value, FocasStatusMapper.Good);
}
+ ///
+ /// Range read for the PMC coalescer (issue #266). FWLIB's pmc_rdpmcrng
+ /// payload is capped at 40 bytes (the IODBPMC.Data union width), so requested
+ /// ranges larger than that are chunked into 32-byte sub-calls internally —
+ /// callers still see one logical range, which matches the
+ /// 's "one wire call per group" semantics.
+ ///
+ public Task<(byte[]? buffer, uint status)> ReadPmcRangeAsync(
+ string letter, int pathId, int startByte, int byteCount, CancellationToken cancellationToken)
+ {
+ if (!_connected) return Task.FromResult<(byte[]?, uint)>((null, FocasStatusMapper.BadCommunicationError));
+ cancellationToken.ThrowIfCancellationRequested();
+ if (byteCount <= 0) return Task.FromResult<(byte[]?, uint)>((Array.Empty(), FocasStatusMapper.Good));
+
+ var addrType = FocasPmcAddrType.FromLetter(letter)
+ ?? throw new InvalidOperationException($"Unknown PMC letter '{letter}'.");
+ var result = new byte[byteCount];
+ const int chunkBytes = 32;
+ var offset = 0;
+ while (offset < byteCount)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var thisChunk = Math.Min(chunkBytes, byteCount - offset);
+ var buf = new FwlibNative.IODBPMC { Data = new byte[40] };
+ var ret = FwlibNative.PmcRdPmcRng(
+ _handle, addrType, FocasPmcDataType.Byte,
+ (ushort)(startByte + offset),
+ (ushort)(startByte + offset + thisChunk - 1),
+ (ushort)(8 + thisChunk), ref buf);
+ if (ret != 0) return Task.FromResult<(byte[]?, uint)>((null, FocasStatusMapper.MapFocasReturn(ret)));
+ Array.Copy(buf.Data, 0, result, offset, thisChunk);
+ offset += thisChunk;
+ }
+ return Task.FromResult<(byte[]?, uint)>((result, FocasStatusMapper.Good));
+ }
+
private uint WritePmc(FocasAddress address, FocasDataType type, object? value)
{
var addrType = FocasPmcAddrType.FromLetter(address.PmcLetter ?? "") ?? (short)0;
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs
index 14e09b4..7e5d986 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs
@@ -184,6 +184,45 @@ public interface IFocasClient : IDisposable
///
Task SetPathAsync(int pathId, CancellationToken cancellationToken)
=> Task.CompletedTask;
+
+ ///
+ /// Read a contiguous range of PMC bytes in a single wire call (FOCAS
+ /// pmc_rdpmcrng with byte data type) for the given
+ /// (R, D, X, etc.) starting at and
+ /// spanning bytes. Returned tuple has the byte buffer
+ /// (length on success) + the OPC UA status mapped through
+ /// . Used by to coalesce
+ /// same-letter/same-path PMC reads in a batch into one round trip per range
+ /// (issue #266 — see ).
+ ///
+ /// Default falls back to per-byte
+ /// calls so transport variants that haven't extended their wire surface still work
+ /// correctly — they just won't see the round-trip reduction. The fallback short-circuits
+ /// on the first non-Good status so a partial buffer isn't returned with a Good code.
+ ///
+ ///
+ async Task<(byte[]? buffer, uint status)> ReadPmcRangeAsync(
+ string letter, int pathId, int startByte, int byteCount, CancellationToken cancellationToken)
+ {
+ if (byteCount <= 0) return (Array.Empty(), FocasStatusMapper.Good);
+ var buf = new byte[byteCount];
+ for (var i = 0; i < byteCount; i++)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var addr = new FocasAddress(FocasAreaKind.Pmc, letter, startByte + i, BitIndex: null, PathId: pathId);
+ var (value, status) = await ReadAsync(addr, FocasDataType.Byte, cancellationToken).ConfigureAwait(false);
+ if (status != FocasStatusMapper.Good) return (null, status);
+ buf[i] = value switch
+ {
+ sbyte s => unchecked((byte)s),
+ byte b => b,
+ int n => unchecked((byte)n),
+ short s => unchecked((byte)s),
+ _ => 0,
+ };
+ }
+ return (buf, FocasStatusMapper.Good);
+ }
}
///
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasPmcCoalescer.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasPmcCoalescer.cs
new file mode 100644
index 0000000..905920b
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasPmcCoalescer.cs
@@ -0,0 +1,152 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
+
+///
+/// One PMC byte-level read request a caller wants to satisfy. is
+/// how many consecutive PMC bytes the caller's tag occupies (e.g. Bit/Byte = 1, Int16 = 2,
+/// Int32/Float32 = 4, Float64 = 8). is the caller's row index
+/// in the batch — the coalescer carries it through to the planned group so the driver can
+/// fan-out the slice back to the original snapshot slot.
+///
+///
+/// Bit-addressed PMC tags (e.g. R100.3) supply their parent byte (100) +
+/// ByteWidth = 1; the slice-then-mask happens in the existing decode path, so
+/// the coalescer doesn't need to know about the bit index.
+///
+public sealed record PmcAddressRequest(
+ string Letter,
+ int PathId,
+ int ByteNumber,
+ int ByteWidth,
+ int OriginalIndex);
+
+///
+/// One member of a coalesced PMC range — the original index + the byte offset within
+/// the planned range buffer where the member's bytes start. The caller slices
+/// buffer[Offset .. Offset + ByteWidth] to recover the per-tag wire-shape bytes.
+///
+public sealed record PmcRangeMember(int OriginalIndex, int Offset, int ByteWidth);
+
+///
+/// One coalesced PMC range — a single FOCAS pmc_rdpmcrng wire call satisfies
+/// every member. is bounded by .
+///
+public sealed record PmcRangeGroup(
+ string Letter,
+ int PathId,
+ int StartByte,
+ int ByteCount,
+ IReadOnlyList Members);
+
+///
+/// Plans one or more coalesced PMC range reads from a flat batch of per-tag requests.
+/// Same-letter / same-path requests whose byte ranges overlap or whose gap is no larger
+/// than are merged into a single wire call up to a
+/// cap (issue #266).
+///
+///
+/// The cap matches the conservative ceiling Fanuc spec lists for
+/// pmc_rdpmcrng — most controllers accept larger ranges but 256 is the lowest
+/// common denominator across 0i / 16i / 30i firmware. Splitting on the cap is fine —
+/// each partition still saves N-1 round trips relative to per-byte reads.
+///
+/// The = 16 mirrors the Modbus coalescer's bridge
+/// policy: small gaps are cheaper to over-read (one extra wire call vs. several short
+/// ones) but unbounded bridging would pull large unused regions over the wire on sparse
+/// PMC layouts.
+///
+public static class FocasPmcCoalescer
+{
+ /// Maximum bytes per coalesced range — conservative ceiling for older Fanuc firmware.
+ public const int MaxRangeBytes = 256;
+
+ /// Maximum gap (in bytes) bridged between consecutive sub-requests within a group.
+ public const int MaxBridgeGap = 16;
+
+ ///
+ /// Plan range reads from . Group key is
+ /// (Letter, PathId). Within a group, requests are sorted by start byte then
+ /// greedily packed into ranges that respect +
+ /// .
+ ///
+ public static IReadOnlyList Plan(IEnumerable addresses)
+ {
+ ArgumentNullException.ThrowIfNull(addresses);
+
+ var groups = new List();
+ var byKey = addresses
+ .Where(a => !string.IsNullOrEmpty(a.Letter) && a.ByteWidth > 0 && a.ByteNumber >= 0)
+ .GroupBy(a => (Letter: a.Letter.ToUpperInvariant(), a.PathId));
+
+ foreach (var key in byKey)
+ {
+ var sorted = key.OrderBy(a => a.ByteNumber).ThenBy(a => a.OriginalIndex).ToList();
+ var pending = new List();
+ var rangeStart = -1;
+ var rangeEnd = -1;
+
+ void Flush()
+ {
+ if (pending.Count == 0) return;
+ var members = pending.Select(p =>
+ new PmcRangeMember(p.OriginalIndex, p.ByteNumber - rangeStart, p.ByteWidth)).ToList();
+ groups.Add(new PmcRangeGroup(key.Key.Letter, key.Key.PathId, rangeStart,
+ rangeEnd - rangeStart + 1, members));
+ pending.Clear();
+ rangeStart = -1;
+ rangeEnd = -1;
+ }
+
+ foreach (var req in sorted)
+ {
+ var reqStart = req.ByteNumber;
+ var reqEnd = req.ByteNumber + req.ByteWidth - 1;
+ if (pending.Count == 0)
+ {
+ rangeStart = reqStart;
+ rangeEnd = reqEnd;
+ pending.Add(req);
+ continue;
+ }
+
+ // Bridge if the gap between the existing range end + this request's start is
+ // within the bridge cap. Overlapping or contiguous ranges always bridge
+ // (gap <= 0). The cap is enforced on the projected union: extending the range
+ // must not exceed MaxRangeBytes from rangeStart.
+ var gap = reqStart - rangeEnd - 1;
+ var projectedEnd = Math.Max(rangeEnd, reqEnd);
+ var projectedSize = projectedEnd - rangeStart + 1;
+ if (gap <= MaxBridgeGap && projectedSize <= MaxRangeBytes)
+ {
+ rangeEnd = projectedEnd;
+ pending.Add(req);
+ }
+ else
+ {
+ Flush();
+ rangeStart = reqStart;
+ rangeEnd = reqEnd;
+ pending.Add(req);
+ }
+ }
+
+ Flush();
+ }
+
+ return groups;
+ }
+
+ ///
+ /// The number of consecutive PMC bytes a tag of occupies on
+ /// the wire. Used by the driver to populate
+ /// before planning. Bit-addressed tags supply 1 here — the bit-extract happens
+ /// in the decode path after the slice.
+ ///
+ public static int ByteWidth(FocasDataType type) => type switch
+ {
+ FocasDataType.Bit or FocasDataType.Byte => 1,
+ FocasDataType.Int16 => 2,
+ FocasDataType.Int32 or FocasDataType.Float32 => 4,
+ FocasDataType.Float64 => 8,
+ _ => 1,
+ };
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs
index c332bd4..07fa8e5 100644
--- a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs
@@ -80,6 +80,33 @@ internal class FakeFocasClient : IFocasClient
return Task.CompletedTask;
}
+ ///
+ /// Per-letter / per-path byte storage the coalesced range path reads from. Tests
+ /// populate PmcByteRanges[("R", 1)] = new byte[size] + the corresponding values to
+ /// drive both the per-tag + the coalesced
+ /// path against the same source of truth (issue #266).
+ ///
+ public Dictionary<(string Letter, int PathId), byte[]> PmcByteRanges { get; } = new();
+
+ ///
+ /// Ordered log of pmc_rdpmcrng-shaped range calls observed on this fake
+ /// session — one entry per coalesced wire call. Tests assert this count to verify
+ /// coalescing actually collapsed N per-byte reads into one range read (issue #266).
+ ///
+ public List<(string Letter, int PathId, int StartByte, int ByteCount)> RangeReadLog { get; } = new();
+
+ public virtual Task<(byte[]? buffer, uint status)> ReadPmcRangeAsync(
+ string letter, int pathId, int startByte, int byteCount, CancellationToken ct)
+ {
+ RangeReadLog.Add((letter, pathId, startByte, byteCount));
+ if (!PmcByteRanges.TryGetValue((letter.ToUpperInvariant(), pathId), out var src))
+ return Task.FromResult<(byte[]?, uint)>((new byte[byteCount], FocasStatusMapper.Good));
+ var buf = new byte[byteCount];
+ var copy = Math.Min(byteCount, Math.Max(0, src.Length - startByte));
+ if (copy > 0) Array.Copy(src, startByte, buf, 0, copy);
+ return Task.FromResult<(byte[]?, uint)>((buf, FocasStatusMapper.Good));
+ }
+
public virtual void Dispose()
{
DisposeCount++;
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPmcCoalescedReadTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPmcCoalescedReadTests.cs
new file mode 100644
index 0000000..cdc5482
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPmcCoalescedReadTests.cs
@@ -0,0 +1,232 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
+
+[Trait("Category", "Unit")]
+public sealed class FocasPmcCoalescedReadTests
+{
+ private const string Host = "focas://10.0.0.5:8193";
+
+ private static (FocasDriver drv, FakeFocasClientFactory factory, FakeFocasClient client) NewDriver(
+ params FocasTagDefinition[] tags)
+ {
+ var client = new FakeFocasClient();
+ var factory = new FakeFocasClientFactory { Customise = () => client };
+ var drv = new FocasDriver(new FocasDriverOptions
+ {
+ Devices = [new FocasDeviceOptions(Host)],
+ Tags = tags,
+ Probe = new FocasProbeOptions { Enabled = false },
+ }, "drv-coalesce", factory);
+ return (drv, factory, client);
+ }
+
+ private static FocasTagDefinition Tag(string name, string addr, FocasDataType type) =>
+ new(name, Host, addr, type);
+
+ [Fact]
+ public async Task Hundred_contiguous_PMC_bytes_collapse_to_one_wire_call()
+ {
+ // 100 contiguous R-letter byte-shaped tags → coalescer cap is 256 → one range read.
+ var tags = new FocasTagDefinition[100];
+ var refs = new string[100];
+ for (var i = 0; i < 100; i++)
+ {
+ tags[i] = Tag($"r{i}", $"R{i}", FocasDataType.Byte);
+ refs[i] = $"r{i}";
+ }
+ var (drv, _, client) = NewDriver(tags);
+ client.PmcByteRanges[("R", 1)] = new byte[200];
+ for (var i = 0; i < 100; i++) client.PmcByteRanges[("R", 1)][i] = (byte)(i + 1);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var snapshots = await drv.ReadAsync(refs, CancellationToken.None);
+
+ snapshots.Count.ShouldBe(100);
+ foreach (var s in snapshots) s.StatusCode.ShouldBe(FocasStatusMapper.Good);
+ // sbyte cast: 1 → 1, 100 stays positive
+ snapshots[0].Value.ShouldBe((sbyte)1);
+ snapshots[99].Value.ShouldBe((sbyte)100);
+ client.RangeReadLog.Count.ShouldBe(1);
+ client.RangeReadLog[0].Letter.ShouldBe("R");
+ client.RangeReadLog[0].StartByte.ShouldBe(0);
+ client.RangeReadLog[0].ByteCount.ShouldBe(100);
+ }
+
+ [Fact]
+ public async Task Gap_larger_than_bridge_threshold_splits_into_two_wire_calls()
+ {
+ // R0, R1, then R100, R101 — gap of 98 > bridge cap of 16 → 2 ranges.
+ var tags = new[]
+ {
+ Tag("r0", "R0", FocasDataType.Byte),
+ Tag("r1", "R1", FocasDataType.Byte),
+ Tag("r100", "R100", FocasDataType.Byte),
+ Tag("r101", "R101", FocasDataType.Byte),
+ };
+ var (drv, _, client) = NewDriver(tags);
+ client.PmcByteRanges[("R", 1)] = new byte[200];
+ client.PmcByteRanges[("R", 1)][0] = 10;
+ client.PmcByteRanges[("R", 1)][1] = 11;
+ client.PmcByteRanges[("R", 1)][100] = 20;
+ client.PmcByteRanges[("R", 1)][101] = 21;
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var snapshots = await drv.ReadAsync(["r0", "r1", "r100", "r101"], CancellationToken.None);
+
+ snapshots.Count.ShouldBe(4);
+ foreach (var s in snapshots) s.StatusCode.ShouldBe(FocasStatusMapper.Good);
+ snapshots[0].Value.ShouldBe((sbyte)10);
+ snapshots[3].Value.ShouldBe((sbyte)21);
+ client.RangeReadLog.Count.ShouldBe(2);
+ }
+
+ [Fact]
+ public async Task Different_letters_yield_separate_wire_calls()
+ {
+ var tags = new[]
+ {
+ Tag("r0", "R0", FocasDataType.Byte),
+ Tag("r1", "R1", FocasDataType.Byte),
+ Tag("d0", "D0", FocasDataType.Byte),
+ Tag("d1", "D1", FocasDataType.Byte),
+ };
+ var (drv, _, client) = NewDriver(tags);
+ client.PmcByteRanges[("R", 1)] = new byte[8] { 1, 2, 3, 4, 5, 6, 7, 8 };
+ client.PmcByteRanges[("D", 1)] = new byte[8] { 9, 10, 11, 12, 13, 14, 15, 16 };
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var snapshots = await drv.ReadAsync(["r0", "r1", "d0", "d1"], CancellationToken.None);
+
+ foreach (var s in snapshots) s.StatusCode.ShouldBe(FocasStatusMapper.Good);
+ snapshots[0].Value.ShouldBe((sbyte)1);
+ snapshots[2].Value.ShouldBe((sbyte)9);
+ client.RangeReadLog.Count.ShouldBe(2);
+ client.RangeReadLog.ShouldContain(c => c.Letter == "R");
+ client.RangeReadLog.ShouldContain(c => c.Letter == "D");
+ }
+
+ [Fact]
+ public async Task Different_paths_yield_separate_wire_calls()
+ {
+ var tags = new[]
+ {
+ Tag("p1a", "R0", FocasDataType.Byte),
+ Tag("p1b", "R1", FocasDataType.Byte),
+ Tag("p2a", "R0@2", FocasDataType.Byte),
+ Tag("p2b", "R1@2", FocasDataType.Byte),
+ };
+ var (drv, _, client) = NewDriver(tags);
+ client.PathCount = 2;
+ client.PmcByteRanges[("R", 1)] = new byte[] { 1, 2 };
+ client.PmcByteRanges[("R", 2)] = new byte[] { 7, 8 };
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var snapshots = await drv.ReadAsync(["p1a", "p1b", "p2a", "p2b"], CancellationToken.None);
+
+ foreach (var s in snapshots) s.StatusCode.ShouldBe(FocasStatusMapper.Good);
+ snapshots[0].Value.ShouldBe((sbyte)1);
+ snapshots[2].Value.ShouldBe((sbyte)7);
+ client.RangeReadLog.Count.ShouldBe(2);
+ client.RangeReadLog.ShouldContain(c => c.PathId == 1);
+ client.RangeReadLog.ShouldContain(c => c.PathId == 2);
+ }
+
+ [Fact]
+ public async Task Single_PMC_tag_does_not_invoke_range_path()
+ {
+ // Single-tag PMC reads aren't worth coalescing — the driver falls through to the
+ // existing per-tag dispatch so connect/set-path overhead isn't paid twice.
+ var (drv, _, client) = NewDriver(Tag("only", "R5", FocasDataType.Byte));
+ client.Values["R5"] = (sbyte)42;
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var snapshots = await drv.ReadAsync(["only"], CancellationToken.None);
+
+ snapshots.Single().Value.ShouldBe((sbyte)42);
+ client.RangeReadLog.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public async Task Bit_addressed_tags_share_parent_byte_range()
+ {
+ // R10.0 + R10.3 + R10.7 + R11.0 — all addressing R10 / R11, one coalesced range.
+ var tags = new[]
+ {
+ Tag("b0", "R10.0", FocasDataType.Bit),
+ Tag("b3", "R10.3", FocasDataType.Bit),
+ Tag("b7", "R10.7", FocasDataType.Bit),
+ Tag("c0", "R11.0", FocasDataType.Bit),
+ };
+ var (drv, _, client) = NewDriver(tags);
+ client.PmcByteRanges[("R", 1)] = new byte[20];
+ // R10 = 0b1000_1001 → bit0=1, bit3=1, bit7=1
+ client.PmcByteRanges[("R", 1)][10] = 0b1000_1001;
+ // R11 = 0b0000_0000 → bit0=0
+ client.PmcByteRanges[("R", 1)][11] = 0;
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var snapshots = await drv.ReadAsync(["b0", "b3", "b7", "c0"], CancellationToken.None);
+
+ snapshots[0].Value.ShouldBe(true);
+ snapshots[1].Value.ShouldBe(true);
+ snapshots[2].Value.ShouldBe(true);
+ snapshots[3].Value.ShouldBe(false);
+ client.RangeReadLog.Count.ShouldBe(1);
+ client.RangeReadLog[0].StartByte.ShouldBe(10);
+ client.RangeReadLog[0].ByteCount.ShouldBe(2);
+ }
+
+ [Fact]
+ public async Task Wider_types_decode_correctly_from_coalesced_buffer()
+ {
+ // R0 Int16 + R2 Int32 + R6 Byte — contiguous (R0..R6 = 7 bytes), one range.
+ var tags = new[]
+ {
+ Tag("w16", "R0", FocasDataType.Int16),
+ Tag("w32", "R2", FocasDataType.Int32),
+ Tag("b", "R6", FocasDataType.Byte),
+ };
+ var (drv, _, client) = NewDriver(tags);
+ // Little-endian: R0..R1 = 0x1234 → bytes 0x34, 0x12; R2..R5 = 0x12345678 → 0x78,0x56,0x34,0x12; R6=0x05
+ client.PmcByteRanges[("R", 1)] = new byte[] { 0x34, 0x12, 0x78, 0x56, 0x34, 0x12, 0x05 };
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var snapshots = await drv.ReadAsync(["w16", "w32", "b"], CancellationToken.None);
+
+ snapshots[0].Value.ShouldBe((short)0x1234);
+ snapshots[1].Value.ShouldBe(0x12345678);
+ snapshots[2].Value.ShouldBe((sbyte)0x05);
+ client.RangeReadLog.Count.ShouldBe(1);
+ client.RangeReadLog[0].ByteCount.ShouldBe(7);
+ }
+
+ [Fact]
+ public async Task Non_PMC_tags_in_same_batch_use_per_tag_path()
+ {
+ // Mix PMC + Parameter + Macro — only the PMC half coalesces.
+ var tags = new[]
+ {
+ Tag("r0", "R0", FocasDataType.Byte),
+ Tag("r1", "R1", FocasDataType.Byte),
+ Tag("p", "PARAM:1820", FocasDataType.Int32),
+ Tag("m", "MACRO:500", FocasDataType.Float64),
+ };
+ var (drv, _, client) = NewDriver(tags);
+ client.PmcByteRanges[("R", 1)] = new byte[] { 11, 22 };
+ client.Values["PARAM:1820"] = 7777;
+ client.Values["MACRO:500"] = 2.71828;
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var snapshots = await drv.ReadAsync(["r0", "r1", "p", "m"], CancellationToken.None);
+
+ snapshots[0].Value.ShouldBe((sbyte)11);
+ snapshots[1].Value.ShouldBe((sbyte)22);
+ snapshots[2].Value.ShouldBe(7777);
+ snapshots[3].Value.ShouldBe(2.71828);
+ client.RangeReadLog.Count.ShouldBe(1);
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPmcCoalescerTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPmcCoalescerTests.cs
new file mode 100644
index 0000000..b622be3
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPmcCoalescerTests.cs
@@ -0,0 +1,176 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
+using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
+
+[Trait("Category", "Unit")]
+public sealed class FocasPmcCoalescerTests
+{
+ [Fact]
+ public void Empty_input_yields_no_groups()
+ {
+ var groups = FocasPmcCoalescer.Plan(Array.Empty());
+ groups.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void Contiguous_same_letter_same_path_coalesces_into_one_group()
+ {
+ // 100 contiguous R-letter byte reads at byte 0..99
+ var requests = new List();
+ for (var i = 0; i < 100; i++)
+ requests.Add(new PmcAddressRequest("R", PathId: 1, ByteNumber: i, ByteWidth: 1, OriginalIndex: i));
+
+ var groups = FocasPmcCoalescer.Plan(requests);
+
+ groups.Count.ShouldBe(1);
+ var g = groups[0];
+ g.Letter.ShouldBe("R");
+ g.PathId.ShouldBe(1);
+ g.StartByte.ShouldBe(0);
+ g.ByteCount.ShouldBe(100);
+ g.Members.Count.ShouldBe(100);
+ g.Members[42].Offset.ShouldBe(42);
+ g.Members[42].OriginalIndex.ShouldBe(42);
+ }
+
+ [Fact]
+ public void Range_cap_splits_oversized_runs_into_multiple_groups()
+ {
+ // 300 contiguous bytes — must split (cap = 256)
+ var requests = new List();
+ for (var i = 0; i < 300; i++)
+ requests.Add(new PmcAddressRequest("R", 1, i, 1, i));
+
+ var groups = FocasPmcCoalescer.Plan(requests);
+
+ groups.Count.ShouldBe(2);
+ groups[0].ByteCount.ShouldBe(FocasPmcCoalescer.MaxRangeBytes);
+ groups[0].StartByte.ShouldBe(0);
+ groups[1].StartByte.ShouldBe(FocasPmcCoalescer.MaxRangeBytes);
+ groups[1].ByteCount.ShouldBe(300 - FocasPmcCoalescer.MaxRangeBytes);
+ }
+
+ [Fact]
+ public void Gap_within_bridge_threshold_is_bridged()
+ {
+ // Two runs: R0..R9 then R20..R29 — gap = 10 bytes, within bridge cap of 16.
+ var requests = new List
+ {
+ new("R", 1, 0, 1, 0),
+ new("R", 1, 9, 1, 1),
+ new("R", 1, 20, 1, 2),
+ new("R", 1, 29, 1, 3),
+ };
+
+ var groups = FocasPmcCoalescer.Plan(requests);
+
+ groups.Count.ShouldBe(1);
+ groups[0].StartByte.ShouldBe(0);
+ groups[0].ByteCount.ShouldBe(30);
+ }
+
+ [Fact]
+ public void Gap_larger_than_bridge_threshold_splits()
+ {
+ // Two runs: R0 then R100 — gap of 99 bytes >> 16, must split.
+ var requests = new List
+ {
+ new("R", 1, 0, 1, 0),
+ new("R", 1, 100, 1, 1),
+ };
+
+ var groups = FocasPmcCoalescer.Plan(requests);
+
+ groups.Count.ShouldBe(2);
+ groups[0].StartByte.ShouldBe(0);
+ groups[1].StartByte.ShouldBe(100);
+ }
+
+ [Fact]
+ public void Different_letters_split_into_separate_groups()
+ {
+ var requests = new List
+ {
+ new("R", 1, 0, 1, 0),
+ new("R", 1, 1, 1, 1),
+ new("D", 1, 0, 1, 2),
+ new("D", 1, 1, 1, 3),
+ };
+
+ var groups = FocasPmcCoalescer.Plan(requests);
+
+ groups.Count.ShouldBe(2);
+ groups.ShouldContain(g => g.Letter == "R" && g.ByteCount == 2);
+ groups.ShouldContain(g => g.Letter == "D" && g.ByteCount == 2);
+ }
+
+ [Fact]
+ public void Different_paths_split_into_separate_groups()
+ {
+ var requests = new List
+ {
+ new("R", 1, 0, 1, 0),
+ new("R", 1, 1, 1, 1),
+ new("R", 2, 0, 1, 2),
+ new("R", 2, 1, 1, 3),
+ };
+
+ var groups = FocasPmcCoalescer.Plan(requests);
+
+ groups.Count.ShouldBe(2);
+ groups.ShouldContain(g => g.Letter == "R" && g.PathId == 1);
+ groups.ShouldContain(g => g.Letter == "R" && g.PathId == 2);
+ }
+
+ [Fact]
+ public void Wider_data_types_extend_range_correctly()
+ {
+ // R0 is Int32 (4 bytes covers R0..R3), R4 is Byte → contiguous, one group of 5 bytes.
+ var requests = new List
+ {
+ new("R", 1, 0, ByteWidth: 4, 0),
+ new("R", 1, 4, ByteWidth: 1, 1),
+ };
+
+ var groups = FocasPmcCoalescer.Plan(requests);
+
+ groups.Count.ShouldBe(1);
+ groups[0].ByteCount.ShouldBe(5);
+ groups[0].Members[0].ByteWidth.ShouldBe(4);
+ groups[0].Members[0].Offset.ShouldBe(0);
+ groups[0].Members[1].ByteWidth.ShouldBe(1);
+ groups[0].Members[1].Offset.ShouldBe(4);
+ }
+
+ [Fact]
+ public void Overlapping_requests_do_not_grow_range_beyond_their_union()
+ {
+ // R10 Int32 (R10..R13) + R12 Byte — overlap; range should still be 4 bytes from 10.
+ var requests = new List
+ {
+ new("R", 1, 10, 4, 0),
+ new("R", 1, 12, 1, 1),
+ };
+
+ var groups = FocasPmcCoalescer.Plan(requests);
+
+ groups.Count.ShouldBe(1);
+ groups[0].StartByte.ShouldBe(10);
+ groups[0].ByteCount.ShouldBe(4);
+ groups[0].Members[1].Offset.ShouldBe(2); // member at byte 12, offset within range = 2
+ }
+
+ [Fact]
+ public void ByteWidth_helper_matches_data_type_sizes()
+ {
+ FocasPmcCoalescer.ByteWidth(FocasDataType.Bit).ShouldBe(1);
+ FocasPmcCoalescer.ByteWidth(FocasDataType.Byte).ShouldBe(1);
+ FocasPmcCoalescer.ByteWidth(FocasDataType.Int16).ShouldBe(2);
+ FocasPmcCoalescer.ByteWidth(FocasDataType.Int32).ShouldBe(4);
+ FocasPmcCoalescer.ByteWidth(FocasDataType.Float32).ShouldBe(4);
+ FocasPmcCoalescer.ByteWidth(FocasDataType.Float64).ShouldBe(8);
+ }
+}