diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs
index 6f9cde21..36b21186 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs
@@ -48,6 +48,13 @@ public sealed class AbLegacyDriverOptions
/// Gets or sets the default timeout for read/write operations.
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
+ ///
+ /// Whether scalar tags on adjacent elements of one N / B / F / L file are read as ONE multi-element PCCC read
+ /// (driver-config key coalesceReads, default ). A block the device refuses falls
+ /// back to per-tag reads on its own; set this false to read every tag singly.
+ ///
+ public bool CoalesceReads { get; init; } = true;
+
///
/// Timeout for the AdminUI Test Connect probe, in seconds. The AdminUI clamps to a
/// 60s server-side maximum; this default is what the form pre-fills for new instances.
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.Coalescing.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.Coalescing.cs
new file mode 100644
index 00000000..c1c6fac1
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.Coalescing.cs
@@ -0,0 +1,242 @@
+using Microsoft.Extensions.Logging;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
+
+///
+/// Adjacent-element read coalescing (review 07 P7): scalar tags on the same N / B / F / L data file whose
+/// elements are close together are read as ONE multi-element PCCC read (the same path array tags already use),
+/// then each tag is decoded from its offset in the block. One round trip instead of one per tag.
+///
+///
+/// Only shapes the single-tag path decodes identically are coalesced: a word tag
+/// (Int/AnalogInt) or a bit of a word (Bit, with or without /n) on an N or B file; a
+/// Float on an F file; a Long or a bit of a long on an L file. Indirect addresses, sub-elements
+/// (T4:0.ACC), arrays, the file-number-less system files (S:, I:, O:) and any tag
+/// authored with a type that does not match its file's element width keep the single-tag path, so a
+/// mis-authored tag still gets exactly the answer it got before.
+/// The span is safe to read. A block runs from its lowest to its highest member, so every element
+/// it reads lies between two authored elements of the same file — inside the file whenever the authored tags
+/// are. A device REFUSAL of a block (an authored element past the end of the file, a PLC-5 native non-N file)
+/// falls back to single reads for that block's members, so one bad tag never blackens its neighbours, and the
+/// block shape is remembered so it is not retried every poll. A comms fault fails the members together — they
+/// share the connection — exactly as their single reads would have.
+/// Turned off with the driver-config key coalesceReads: false.
+///
+public sealed partial class AbLegacyDriver
+{
+ /// Largest block, in data bytes. Below the 236-byte PCCC typed-read limit, so libplctag never needs
+ /// to fragment a coalesced read (the native runtime would chunk it anyway).
+ internal const int MaxCoalescedBytes = 200;
+
+ /// Largest run of UNAUTHORED elements a block may read across to join two members.
+ internal const int MaxCoalesceGap = 8;
+
+ /// One unit of a device pass: a single tag, or a coalesced block of several.
+ private sealed record ReadUnit(ReadItem? Single, CoalescedBlock? Block);
+
+ /// A coalesced block: elements of one file from .
+ private sealed record CoalescedBlock(
+ string FileLetter, int FileNumber, int Start, int Count, AbLegacyDataType ElementType, List Members)
+ {
+ /// The runtime-cache key; distinct from any RawPath (RawPaths never contain '|').
+ public string Key => $"|block|{FileLetter}{FileNumber}:{Start}+{Count}";
+
+ /// The block's base address in libplctag form.
+ public string StartName => $"{FileLetter}{FileNumber}:{Start}";
+ }
+
+ ///
+ /// The element type a scalar tag is decoded from when it rides a coalesced block, or
+ /// when the tag keeps the single-tag path.
+ ///
+ private static AbLegacyDataType? CoalescedElementType(ReadItem item)
+ {
+ var a = item.Parsed;
+ if (a.IsIndirect || a.SubElement is not null || a.FileNumber is null || IsArrayTag(item.Def)) return null;
+ return (a.FileLetter, item.Def.DataType) switch
+ {
+ ("N" or "B", AbLegacyDataType.Int or AbLegacyDataType.AnalogInt or AbLegacyDataType.Bit) => AbLegacyDataType.Int,
+ ("F", AbLegacyDataType.Float) => AbLegacyDataType.Float,
+ ("L", AbLegacyDataType.Long) => AbLegacyDataType.Long,
+ ("L", AbLegacyDataType.Bit) when a.BitIndex is not null => AbLegacyDataType.Long,
+ _ => null,
+ };
+ }
+
+ ///
+ /// Plans a device pass: coalescable tags grouped per file into runs of nearby elements (a run of two or more
+ /// tags becomes a block), everything else a single unit. Block shapes the device refused before stay single.
+ ///
+ private List PlanReadUnits(DeviceState device, List items)
+ {
+ if (!_options.CoalesceReads || items.Count < 2) return [.. items.Select(i => new ReadUnit(i, null))];
+
+ var units = new List(items.Count);
+ var groups = new Dictionary<(string Letter, int File, AbLegacyDataType Type), List>();
+ foreach (var item in items)
+ {
+ if (CoalescedElementType(item) is not { } type)
+ {
+ units.Add(new ReadUnit(item, null));
+ continue;
+ }
+ var key = (item.Parsed.FileLetter, item.Parsed.FileNumber!.Value, type);
+ if (!groups.TryGetValue(key, out var list)) groups[key] = list = [];
+ list.Add(item);
+ }
+
+ foreach (var ((letter, file, type), members) in groups)
+ {
+ var width = type == AbLegacyDataType.Int ? 2 : 4;
+ var maxElements = MaxCoalescedBytes / width;
+ var ordered = members.OrderBy(m => m.Parsed.WordNumber).ToList();
+ var run = new List { ordered[0] };
+ for (var i = 1; i <= ordered.Count; i++)
+ {
+ var next = i < ordered.Count ? ordered[i] : null;
+ var start = run[0].Parsed.WordNumber;
+ if (next is not null
+ && next.Parsed.WordNumber - run[^1].Parsed.WordNumber <= MaxCoalesceGap + 1
+ && next.Parsed.WordNumber - start + 1 <= maxElements)
+ {
+ run.Add(next);
+ continue;
+ }
+ var count = run[^1].Parsed.WordNumber - start + 1;
+ var block = new CoalescedBlock(letter, file, start, count, type, run);
+ if (run.Count < 2 || device.RefusedBlocks.ContainsKey(block.Key))
+ units.AddRange(run.Select(r => new ReadUnit(r, null)));
+ else
+ units.Add(new ReadUnit(null, block));
+ if (next is not null) run = [next];
+ }
+ }
+ return units;
+ }
+
+ /// What a block read concluded; = read the members singly instead.
+ private enum BlockOutcome { Done, Fallback }
+
+ ///
+ /// Reads one coalesced block and decodes each member from its offset. A device refusal or a local refusal
+ /// returns (and remembers the shape) without writing any member's result.
+ ///
+ private async Task<(BlockOutcome Outcome, OpVerdict Verdict, string? Message)> ReadBlockAsync(
+ DeviceState device, CoalescedBlock block, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
+ {
+ void FailAll(uint status)
+ {
+ foreach (var m in block.Members) results[m.Index] = new DataValueSnapshot(null, status, null, now);
+ }
+
+ (BlockOutcome, OpVerdict, string?) Refused(string why)
+ {
+ device.RefusedBlocks.TryAdd(block.Key, 0);
+ _logger.LogInformation(
+ "AbLegacy coalesced read {Block} refused ({Reason}); its {Count} tags read singly from now on. Driver={DriverInstanceId} Device={DeviceHostAddress}",
+ block.StartName + "+" + block.Count, why, block.Members.Count, _driverInstanceId, device.Options.HostAddress);
+ return (BlockOutcome.Fallback, OpVerdict.Neutral, null);
+ }
+
+ try
+ {
+ var createParams = new AbLegacyTagCreateParams(
+ Gateway: device.ParsedAddress.Gateway,
+ Port: device.ParsedAddress.Port,
+ CipPath: device.EffectiveCipPath,
+ LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
+ TagName: block.StartName,
+ Timeout: _options.Timeout,
+ ElementCount: block.Count);
+ var runtime = await CreateSingleFlightAsync(
+ device, device.Runtimes, block.Key, () => _tagFactory.Create(createParams), ct).ConfigureAwait(false);
+
+ int status;
+ Array? data = null;
+ var opLock = device.GetRuntimeLock(block.Key);
+ await opLock.WaitAsync(ct).ConfigureAwait(false);
+ try
+ {
+ await runtime.ReadAsync(ct).ConfigureAwait(false);
+ status = runtime.GetStatus();
+ if (status == 0) data = runtime.DecodeArray(block.ElementType, block.Count) as Array;
+ }
+ finally
+ {
+ opLock.Release();
+ }
+
+ if (status != 0)
+ {
+ if (IsDeviceRefusal((AbStatus)status) || (AbStatus)status == AbStatus.Unsupported)
+ {
+ EvictRuntime(device, block.Key);
+ return Refused($"device status {(AbStatus)status}");
+ }
+ EvictRuntime(device, block.Key);
+ FailAll(AbLegacyStatusMapper.MapStatus(status));
+ return (BlockOutcome.Done, OpVerdict.Fault, $"transport status {status} reading {block.StartName}+{block.Count}");
+ }
+ if (data is null || data.Length < block.Count)
+ return Refused("the block decoded short");
+
+ foreach (var m in block.Members)
+ results[m.Index] = new DataValueSnapshot(DecodeMember(block, m, data), AbLegacyStatusMapper.Good, now, now);
+ device.FirstNonZeroStatusLogged = false;
+ return (BlockOutcome.Done, OpVerdict.Answered, null);
+ }
+ catch (OperationCanceledException) { throw; }
+ catch (ConnectionThrottledException tex)
+ {
+ FailAll(AbLegacyStatusMapper.BadCommunicationError);
+ return (BlockOutcome.Done, OpVerdict.Throttled, tex.Message);
+ }
+ catch (AbTransportException ex) when (IsLocalRefusal(ex) || IsDeviceRefusal(ex.Status))
+ {
+ EvictRuntime(device, block.Key);
+ return Refused(ex.Message);
+ }
+ catch (NotSupportedException nse)
+ {
+ EvictRuntime(device, block.Key);
+ return Refused(nse.Message);
+ }
+ catch (Exception ex)
+ {
+ EvictRuntime(device, block.Key);
+ FailAll(AbLegacyStatusMapper.BadCommunicationError);
+ return (BlockOutcome.Done, OpVerdict.Fault, ex.Message);
+ }
+ }
+
+ ///
+ /// Decodes one member from the block, returning exactly the CLR type and value the single-tag path's
+ /// would (int for a word or long, float, bool).
+ ///
+ private static object DecodeMember(CoalescedBlock block, ReadItem m, Array data)
+ {
+ var offset = m.Parsed.WordNumber - block.Start;
+ var bit = m.Parsed.BitIndex;
+ switch (data)
+ {
+ case short[] words:
+ {
+ var w = words[offset];
+ return m.Def.DataType == AbLegacyDataType.Bit
+ ? ((ushort)w >> (bit ?? 0) & 1) != 0
+ : (int)w;
+ }
+ case int[] longs:
+ {
+ var l = longs[offset];
+ return m.Def.DataType == AbLegacyDataType.Bit ? ((uint)l >> bit!.Value & 1) != 0 : l;
+ }
+ case float[] floats:
+ return floats[offset];
+ default:
+ throw new InvalidOperationException($"Unexpected block buffer {data.GetType().Name}.");
+ }
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs
index 60da49b5..16f626da 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs
@@ -12,7 +12,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// only at PR 1 time; read / write / discovery / subscribe / probe /
/// host-resolver capabilities ship in PRs 2 and 3.
///
-public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
+public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
IHostConnectivityProbe, IPerCallHostResolver, ISkippedTagReporter, IDisposable, IAsyncDisposable
{
/// Authored tags this driver could not build into its poll table.
@@ -565,21 +565,42 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
DeviceState device, List items, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
{
var outcome = new DeviceReadOutcome();
- foreach (var item in items)
+ var work = new Queue(PlanReadUnits(device, items));
+ while (work.TryDequeue(out var unit))
{
+ var members = unit.Block?.Members ?? [unit.Single!];
if (device.IsThrottled(DateTime.UtcNow))
{
- results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadCommunicationError, null, now);
+ foreach (var m in members)
+ results[m.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadCommunicationError, null, now);
outcome.FaultMessage ??= $"device {device.Options.HostAddress} is in its reconnect backoff window";
continue;
}
- var (verdict, message) = await ReadOneAsync(device, item, results, now, ct).ConfigureAwait(false);
+ OpVerdict verdict;
+ string? message;
+ if (unit.Block is { } block)
+ {
+ (var blockOutcome, verdict, message) = await ReadBlockAsync(device, block, results, now, ct).ConfigureAwait(false);
+ if (blockOutcome == BlockOutcome.Fallback)
+ {
+ // Refused as a block: read its members singly, now, so each gets its own answer.
+ var rest = work.ToList();
+ work.Clear();
+ foreach (var m in block.Members) work.Enqueue(new ReadUnit(m, null));
+ foreach (var r in rest) work.Enqueue(r);
+ continue;
+ }
+ }
+ else
+ {
+ (verdict, message) = await ReadOneAsync(device, unit.Single!, results, now, ct).ConfigureAwait(false);
+ }
switch (verdict)
{
case OpVerdict.Answered:
outcome.AnyAnswered = true;
- if (results[item.Index].StatusCode == AbLegacyStatusMapper.Good) outcome.AnyGood = true;
+ if (members.Any(m => results[m.Index].StatusCode == AbLegacyStatusMapper.Good)) outcome.AnyGood = true;
device.RecordAnswered();
break;
case OpVerdict.Fault:
@@ -1685,6 +1706,10 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
///
public bool FirstNonZeroStatusLogged { get; set; }
+ /// Coalesced block shapes the device refused; their members read singly from then on (bounded by the
+ /// distinct subscription shapes, released with the state).
+ public System.Collections.Concurrent.ConcurrentDictionary RefusedBlocks { get; } = new(StringComparer.Ordinal);
+
/// Tag operations already warned about for a deterministic refusal (once-per-tag-and-operation log).
public System.Collections.Concurrent.ConcurrentDictionary RefusalLogged { get; } = new(StringComparer.Ordinal);
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs
index 9dfd9707..d06effd9 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs
@@ -97,6 +97,7 @@ public static class AbLegacyDriverFactoryExtensions
ProbeAddress = dto.Probe?.ProbeAddress ?? "S:0",
},
Timeout = PositiveTimeoutOrDefault(dto.TimeoutMs, 2_000),
+ CoalesceReads = dto.CoalesceReads ?? true,
// Absent ⇒ null ("use the default" = Libplctag, resolved by AbLegacyTransportResolver);
// a bad value still fails exactly as any other bad enum field does.
Transport = string.IsNullOrWhiteSpace(dto.Transport)
@@ -157,6 +158,9 @@ public static class AbLegacyDriverFactoryExtensions
///
public string? Transport { get; init; }
+ /// Gets or sets whether adjacent scalar reads are coalesced; absent means .
+ public bool? CoalesceReads { get; init; }
+
///
/// Gets or sets the list of devices to connect to.
///
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor
index 9db2085a..a938652b 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor
@@ -28,6 +28,13 @@
Which protocol stack talks to the controller. Default omits the setting (libplctag). Native is opt-in: wire-proven only for N-file reads/writes and F8 reads. Takes effect on the next deploy.
+
+
+
+
+
+
Read nearby scalar tags on one N/B/F/L file as a single PCCC request. A block the PLC refuses falls back to per-tag reads automatically. Default on.
+
@@ -97,7 +104,7 @@
/// JSON paths this form edits; everything else in the stored config is preserved.
internal static readonly string[] OwnedPaths =
[
- "transport", "probeTimeoutSeconds",
+ "transport", "probeTimeoutSeconds", "coalesceReads",
"probe.enabled", "probe.probeAddress",
];
@@ -163,6 +170,7 @@
// PLC-5 / LogixPccc exclusion still applies). Never re-emit a concrete default here — that would pin
// every edited driver to it.
public AbLegacyTransport? Transport { get; set; }
+ public bool CoalesceReads { get; set; } = true;
// Probe
public bool ProbeEnabled { get; set; } = true;
@@ -200,6 +208,7 @@
_loadedProbeTimeout = o.Probe.Timeout,
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
Transport = o.Transport,
+ CoalesceReads = o.CoalesceReads,
ProbeEnabled = o.Probe.Enabled,
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
@@ -220,6 +229,7 @@
},
Timeout = DriverConfigMs.Save(TimeoutSeconds, _loadedTimeout),
Transport = Transport,
+ CoalesceReads = CoalesceReads,
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
};
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadCoalescingIntegrationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadCoalescingIntegrationTests.cs
new file mode 100644
index 00000000..2558adc3
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadCoalescingIntegrationTests.cs
@@ -0,0 +1,69 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests;
+
+///
+/// Review 07 P7: a coalesced read of nearby N7 words and bits must decode exactly what per-tag reads return,
+/// on the wire, on both transports. Seeds distinct values first when writes are allowed (simulator only);
+/// otherwise compares whatever the simulator holds.
+///
+[Collection(AbLegacyServerCollection.Name)]
+[Trait("Category", "Integration")]
+[Trait("Simulator", "ab_server-PCCC")]
+public sealed class AbLegacyReadCoalescingIntegrationTests(AbLegacyServerFixture sim)
+{
+ /// Coalesced and per-tag reads of the same tags agree value for value.
+ /// The simulator profile.
+ /// The transport stack the driver runs on.
+ [AbLegacyTheory]
+ [MemberData(nameof(TransportMatrix.Profiles), MemberType = typeof(TransportMatrix))]
+ public async Task Coalesced_reads_match_single_reads(AbLegacyServerProfile profile, AbLegacyTransport transport)
+ {
+ if (sim.SkipReasonFor(profile) is { } skip) Assert.Skip(skip);
+ var ct = TestContext.Current.CancellationToken;
+ var uri = $"ab://{sim.Host}:{sim.PortFor(profile)}/{sim.CipPath}";
+ AbLegacyTagDefinition[] tags =
+ [
+ new("W0", uri, "N7:0", AbLegacyDataType.Int),
+ new("W1", uri, "N7:1", AbLegacyDataType.Int),
+ new("W4", uri, "N7:4", AbLegacyDataType.Int),
+ new("W4b3", uri, "N7:4/3", AbLegacyDataType.Bit),
+ ];
+ string[] refs = [.. tags.Select(t => t.Name)];
+
+ AbLegacyDriver Make(bool coalesce) => new(new AbLegacyDriverOptions
+ {
+ Devices = [new AbLegacyDeviceOptions(uri, profile.Family)],
+ RawTags = AbLegacyRawTags.Entries(tags),
+ Timeout = TimeSpan.FromSeconds(5),
+ Probe = new AbLegacyProbeOptions { Enabled = false },
+ Transport = transport,
+ CoalesceReads = coalesce,
+ }, driverInstanceId: $"ablegacy-coalesce-{coalesce}-{profile.Family}-{transport}");
+
+ await using var single = Make(coalesce: false);
+ await using var coalesced = Make(coalesce: true);
+ await single.InitializeAsync("{}", ct);
+ await coalesced.InitializeAsync("{}", ct);
+
+ var writesRefused = profile.Family == AbLegacyPlcFamily.Plc5 && transport == AbLegacyTransport.Native;
+ if (Environment.GetEnvironmentVariable(IntegrationWriteGate.AllowWritesVar) == "1"
+ && Environment.GetEnvironmentVariable(IntegrationWriteGate.DifferentialEndpointVar) is not { Length: > 0 }
+ && !writesRefused)
+ {
+ (await single.WriteAsync(
+ [new WriteRequest("W0", (short)101), new WriteRequest("W1", (short)-202), new WriteRequest("W4", (short)0x0008)], ct))
+ .ShouldAllBe(r => r.StatusCode == AbLegacyStatusMapper.Good);
+ }
+
+ var a = await single.ReadAsync(refs, ct);
+ var b = await coalesced.ReadAsync(refs, ct);
+
+ a.ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good);
+ b.ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good);
+ b.Select(s => s.Value).ShouldBe(a.Select(s => s.Value));
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDeviceIsolationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDeviceIsolationTests.cs
index a28b8b60..213fea69 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDeviceIsolationTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDeviceIsolationTests.cs
@@ -45,6 +45,7 @@ public sealed class AbLegacyDeviceIsolationTests
Devices = [new AbLegacyDeviceOptions(HostA), new AbLegacyDeviceOptions(HostB)],
RawTags = AbLegacyRawTags.Entries(tags),
Probe = new AbLegacyProbeOptions { Enabled = false },
+ CoalesceReads = false, // per-tag isolation is under test here; coalescing has its own tests
}, "drv-p7", factory);
private static AbLegacyTagDefinition[] Tags(string prefix, string host, int n, string file = "N7") =>
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReadCoalescingTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReadCoalescingTests.cs
new file mode 100644
index 00000000..59fddba0
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReadCoalescingTests.cs
@@ -0,0 +1,182 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
+
+///
+/// Review 07 P7: adjacent N/B/F/L elements of one device were never coalesced (one PCCC round trip per tag),
+/// although the array path proves the transport can read a span. Scalar tags near each other on one file now
+/// ride ONE multi-element read and are decoded from their offsets — with exactly the CLR types the single-tag
+/// path produces — and a block the device refuses falls back to per-tag reads.
+///
+[Trait("Category", "Unit")]
+public sealed class AbLegacyReadCoalescingTests
+{
+ private const string Host = "ab://10.0.0.5/1,0";
+
+ private static (AbLegacyDriver Drv, FakeAbLegacyTagFactory Factory, List Created) NewDriver(
+ Func make, bool coalesce, params AbLegacyTagDefinition[] tags)
+ {
+ var created = new List();
+ var factory = new FakeAbLegacyTagFactory
+ {
+ Customise = p =>
+ {
+ lock (created) created.Add(p);
+ return make(p);
+ },
+ };
+ var drv = new AbLegacyDriver(new AbLegacyDriverOptions
+ {
+ Devices = [new AbLegacyDeviceOptions(Host)],
+ RawTags = AbLegacyRawTags.Entries(tags),
+ Probe = new AbLegacyProbeOptions { Enabled = false },
+ CoalesceReads = coalesce,
+ }, "drv-coalesce", factory);
+ return (drv, factory, created);
+ }
+
+ private static AbLegacyTagDefinition Tag(string name, string address, AbLegacyDataType type) =>
+ new(name, Host, address, type);
+
+ [Fact]
+ public async Task Nearby_N_file_words_and_bits_ride_one_read_and_decode_like_single_reads()
+ {
+ var (drv, _, created) = NewDriver(
+ p => new FakeAbLegacyTag(p) { ArrayValue = new short[] { 10, 11, 12, 4, 14, -15 } },
+ coalesce: true,
+ Tag("W0", "N7:0", AbLegacyDataType.Int),
+ Tag("W1", "N7:1", AbLegacyDataType.AnalogInt),
+ Tag("B32", "N7:3/2", AbLegacyDataType.Bit),
+ Tag("B31", "N7:3/1", AbLegacyDataType.Bit),
+ Tag("W5", "N7:5", AbLegacyDataType.Int));
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var r = await drv.ReadAsync(["W0", "W1", "B32", "B31", "W5"], CancellationToken.None);
+
+ r.ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good);
+ r[0].Value.ShouldBe(10); // int, exactly as DecodeValue(Int) returns
+ r[1].Value.ShouldBe(11);
+ r[2].Value.ShouldBe(true); // word 3 = 0b100: bit 2 set
+ r[3].Value.ShouldBe(false);
+ r[4].Value.ShouldBe(-15);
+ created.Count.ShouldBe(1);
+ created[0].TagName.ShouldBe("N7:0");
+ created[0].ElementCount.ShouldBe(6);
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+
+ [Fact]
+ public async Task Float_and_long_files_coalesce_at_their_element_width()
+ {
+ var (drv, _, created) = NewDriver(
+ p => p.TagName.StartsWith('F')
+ ? new FakeAbLegacyTag(p) { ArrayValue = new[] { 1.5f, 0f, 2.5f } }
+ : new FakeAbLegacyTag(p) { ArrayValue = new[] { 7, int.MinValue } },
+ coalesce: true,
+ Tag("F0", "F8:0", AbLegacyDataType.Float),
+ Tag("F2", "F8:2", AbLegacyDataType.Float),
+ Tag("L0", "L9:0", AbLegacyDataType.Long),
+ Tag("L1b31", "L9:1/31", AbLegacyDataType.Bit));
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var r = await drv.ReadAsync(["F0", "F2", "L0", "L1b31"], CancellationToken.None);
+
+ r.Select(s => s.Value).ShouldBe(new object[] { 1.5f, 2.5f, 7, true });
+ created.Select(c => (c.TagName, c.ElementCount)).OrderBy(x => x.TagName)
+ .ShouldBe(new[] { ("F8:0", 3), ("L9:0", 2) });
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+
+ [Fact]
+ public async Task Far_apart_or_ineligible_tags_keep_the_single_tag_path()
+ {
+ var (drv, _, created) = NewDriver(
+ p => new FakeAbLegacyTag(p) { Value = 1 },
+ coalesce: true,
+ Tag("Near", "N7:0", AbLegacyDataType.Int),
+ Tag("Far", "N7:40", AbLegacyDataType.Int), // gap > MaxCoalesceGap
+ Tag("Mistyped", "N7:1", AbLegacyDataType.Float), // width mismatch: single path answers as before
+ Tag("Acc", "T4:0.ACC", AbLegacyDataType.Int), // sub-element
+ Tag("Status", "S:1", AbLegacyDataType.Int), // no file number
+ Tag("Ind", "N7:[N7:0]", AbLegacyDataType.Int)); // indirect
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ _ = await drv.ReadAsync(["Near", "Far", "Mistyped", "Acc", "Status", "Ind"], CancellationToken.None);
+
+ created.ShouldAllBe(c => c.ElementCount == 1);
+ created.Select(c => c.TagName).ShouldContain("N7:40");
+ created.Select(c => c.TagName).ShouldContain("T4:0.ACC");
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+
+ [Fact]
+ public async Task A_refused_block_falls_back_to_single_reads_and_is_not_retried()
+ {
+ // N7:9 is past the end of the file: the BLOCK is refused; singly, only that tag is.
+ var (drv, _, created) = NewDriver(
+ p => p.ElementCount > 1 || p.TagName == "N7:9"
+ ? new FakeAbLegacyTag(p) { Status = (int)AbStatus.OutOfBounds }
+ : new FakeAbLegacyTag(p) { Value = 3 },
+ coalesce: true,
+ Tag("A", "N7:0", AbLegacyDataType.Int),
+ Tag("B", "N7:2", AbLegacyDataType.Int),
+ Tag("C", "N7:9", AbLegacyDataType.Int));
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ for (var poll = 0; poll < 3; poll++)
+ {
+ var r = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None);
+ r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}");
+ r[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}");
+ r[2].StatusCode.ShouldBe(AbLegacyStatusMapper.MapStatus(AbStatus.OutOfBounds));
+ }
+ created.Count(c => c.ElementCount > 1).ShouldBe(1); // the refused shape is remembered
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy); // refusals are not comms faults
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+
+ [Fact]
+ public async Task A_block_comms_fault_fails_its_members_together_and_degrades()
+ {
+ var (drv, _, _) = NewDriver(
+ p => new FakeAbLegacyTag(p) { ThrowOnRead = true, Exception = new System.Net.Sockets.SocketException(10054) },
+ coalesce: true,
+ Tag("A", "N7:0", AbLegacyDataType.Int),
+ Tag("B", "N7:1", AbLegacyDataType.Int));
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var r = await drv.ReadAsync(["A", "B"], CancellationToken.None);
+
+ r.ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.BadCommunicationError);
+ drv.GetHealth().State.ShouldBe(DriverState.Degraded);
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+
+ [Fact]
+ public async Task CoalesceReads_false_reads_every_tag_singly()
+ {
+ var (drv, _, created) = NewDriver(
+ p => new FakeAbLegacyTag(p) { Value = 1 },
+ coalesce: false,
+ Tag("A", "N7:0", AbLegacyDataType.Int),
+ Tag("B", "N7:1", AbLegacyDataType.Int));
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var r = await drv.ReadAsync(["A", "B"], CancellationToken.None);
+
+ r.ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good);
+ created.Count.ShouldBe(2);
+ created.ShouldAllBe(c => c.ElementCount == 1);
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+
+ [Theory]
+ [InlineData("""{"devices":[{"hostAddress":"ab://10.0.0.5/1,0"}]}""", true)]
+ [InlineData("""{"coalesceReads":false,"devices":[{"hostAddress":"ab://10.0.0.5/1,0"}]}""", false)]
+ [InlineData("""{"coalesceReads":true,"someFutureKey":1,"devices":[{"hostAddress":"ab://10.0.0.5/1,0"}]}""", true)]
+ public void ParseOptions_reads_coalesceReads_defaulting_true(string json, bool expected) =>
+ AbLegacyDriverFactoryExtensions.ParseOptions("drv", json).CoalesceReads.ShouldBe(expected);
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs
index f4297127..162336cc 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs
@@ -256,6 +256,23 @@ public sealed class AbLegacyDriverPageFormSerializationTests
bound.Probe.ProbeAddress.ShouldBe("S:1");
}
+ /// Review 07 P7: the coalesceReads switch is authored here and consumed by the driver factory.
+ [Theory]
+ [InlineData("""{"devices":[]}""", true, false)]
+ [InlineData("""{"coalesceReads":false,"devices":[]}""", false, true)]
+ public void CoalesceReads_round_trips_to_the_factory_bound_options(string original, bool loadedValue, bool edited)
+ {
+ var loaded = AbLegacyDriverForm.TryDeserialize(original)!;
+ var form = AbLegacyDriverForm.FormModel.FromJson(original, loaded);
+ form.CoalesceReads.ShouldBe(loadedValue);
+ form.CoalesceReads = edited;
+
+ var saved = AbLegacyDriverForm.Serialize(form.ToOptions(loaded.Devices), original);
+
+ ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.AbLegacyDriverFactoryExtensions.ParseOptions("t", saved)
+ .CoalesceReads.ShouldBe(edited);
+ }
+
[Fact]
public void A_legacy_TimeSpan_string_key_is_loaded_then_migrated_to_ms_and_removed()
{