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() {