diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasDriverOptions.cs
index 7929cf98..b04f865a 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasDriverOptions.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasDriverOptions.cs
@@ -31,6 +31,8 @@ public sealed class FocasDriverOptions
public FocasHandleRecycleOptions HandleRecycle { get; init; } = new();
/// Gets the fixed tree options.
public FocasFixedTreeOptions FixedTree { get; init; } = new();
+ /// Gets the PMC range-coalescing options (Gitea #266).
+ public FocasPmcCoalesceOptions PmcCoalesce { get; init; } = new();
///
/// Timeout for the AdminUI Test Connect probe, in seconds. The AdminUI clamps to a
@@ -41,6 +43,32 @@ public sealed class FocasDriverOptions
public int ProbeTimeoutSeconds { get; init; } = 10;
}
+///
+/// Merges adjacent / near PMC reads in one poll batch into fewer pmc_rdpmcrng calls (Gitea #266).
+/// On by default: a batch with a single PMC tag per area is unaffected, and a coalesced read the CNC
+/// rejects falls back to per-tag reads, so enabling it cannot make a readable tag unreadable.
+///
+public sealed class FocasPmcCoalesceOptions
+{
+ /// Gets a value indicating whether PMC coalescing is on.
+ public bool Enabled { get; init; } = true;
+
+ ///
+ /// Largest range, in bytes, one coalesced read may cover. Bounds the response size and the
+ /// blast radius of a rejected range. Default 128.
+ ///
+ [Range(1, 1024)]
+ public int MaxSpanBytes { get; init; } = 128;
+
+ ///
+ /// Largest run of bytes nobody asked for that may sit between two merged tags. Reading a few
+ /// spare bytes is cheaper than a second round-trip, but every spare byte must be a valid PMC
+ /// address on the CNC. Default 8; 0 merges only strictly adjacent tags.
+ ///
+ [Range(0, 1024)]
+ public int MaxGapBytes { get; init; } = 8;
+}
+
///
/// Fixed-node tree exposed by FOCAS per docs/v2/driver-specs.md §7 —
/// Identity/, Axes/{name}/, etc. populated from
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
index 0c0be6f7..921164b4 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
@@ -498,8 +498,16 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
var now = DateTime.UtcNow;
var results = new DataValueSnapshot[fullReferences.Count];
+ // #266 — merge adjacent / near PMC reads of one device into fewer pmc_rdpmcrng calls. Indices it
+ // serves are marked here and skipped by the per-tag loop below; everything else (including every
+ // single-tag range and any range the CNC rejected) takes the unchanged per-tag path.
+ var served = _options.PmcCoalesce.Enabled
+ ? await CoalescePmcReadsAsync(fullReferences, results, now, cancellationToken).ConfigureAwait(false)
+ : null;
+
for (var i = 0; i < fullReferences.Count; i++)
{
+ if (served is not null && served[i]) continue;
var reference = fullReferences[i];
// Fixed-tree T1 — fixed-tree references are synthesized from the cached
@@ -581,6 +589,117 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
return results;
}
+ ///
+ /// PMC coalescing pre-pass (Gitea #266). Groups the batch's PMC tags by device, plans merged ranges
+ /// () and serves every multi-member range with ONE
+ /// pmc_rdpmcrng, slicing each tag's value out of the returned slots.
+ ///
+ ///
+ /// Failure handling is the point of the design. A rejected range is NOT mis-attributed to its members:
+ ///
+ /// - a comms-level failure (timeout / transport) fails every member with that status — the
+ /// link is down, so re-reading each member would multiply timeouts;
+ /// - any other non-Good status (typically a spare "gap" byte that is not a valid PMC address)
+ /// leaves the members UNSERVED, so the per-tag loop reads them individually and each gets its own
+ /// true status — and the range is remembered so later polls skip the doomed merged read.
+ ///
+ /// Returns null when nothing was served.
+ ///
+ private async Task CoalescePmcReadsAsync(
+ IReadOnlyList references, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
+ {
+ Dictionary>? byDevice = null;
+ for (var i = 0; i < references.Count; i++)
+ {
+ try
+ {
+ var reference = references[i];
+ if (_fixedTreeActive && TryReadFixedTree(reference, now) is not null) continue;
+ if (!_resolver.TryResolve(reference, out var def)) continue;
+ if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) continue;
+ var parsed = ResolveParsedAddress(def);
+ if (parsed.Kind != FocasAreaKind.Pmc || parsed.PmcLetter is null) continue;
+ (byDevice ??= []).TryAdd(device, []);
+ byDevice[device].Add(new FocasPmcReadItem(i, parsed.PmcLetter, parsed.Number, def.DataType, parsed.BitIndex));
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ /* malformed / unresolvable — the per-tag loop reports it with its usual status */
+ }
+ }
+ if (byDevice is null) return null;
+
+ bool[]? served = null;
+ foreach (var (device, items) in byDevice)
+ {
+ if (items.Count < 2) continue;
+ var plans = FocasPmcCoalescer.Plan(items, _options.PmcCoalesce.MaxSpanBytes, _options.PmcCoalesce.MaxGapBytes);
+ foreach (var plan in plans)
+ {
+ if (plan.Members.Count < 2) continue;
+ if (device.CoalesceRejected.ContainsKey(plan.Key)) continue;
+
+ var members = plan.Members;
+ try
+ {
+ var client = await EnsureConnectedAsync(device, ct).ConfigureAwait(false);
+ var (slots, status) = await client
+ .ReadPmcSlotsAsync(plan.Letter, plan.SlotType, plan.Start, plan.End, ct)
+ .ConfigureAwait(false);
+
+ if (status == FocasStatusMapper.Good && slots is not null)
+ {
+ foreach (var m in members)
+ {
+ var slot = plan.SlotOf(m);
+ results[m.Index] = slot < slots.Count
+ ? new DataValueSnapshot(FocasPmcCoalescer.MapSlot(slots[slot], m.Type, m.BitIndex), status, now, now)
+ : new DataValueSnapshot(null, FocasStatusMapper.BadOutOfRange, now, now);
+ (served ??= new bool[references.Count])[m.Index] = true;
+ }
+ Volatile.Write(ref _health, new DriverHealth(DriverState.Healthy, now, null));
+ }
+ else if (status == FocasStatusMapper.BadCommunicationError)
+ {
+ FailMembers(plan, status, references, results, now, ref served);
+ }
+ else
+ {
+ // The CNC refused the merged range. Don't guess which member is at fault: remember the
+ // range and let the per-tag loop give each member its own answer.
+ device.CoalesceRejected[plan.Key] = 0;
+ _logger.LogDebug(
+ "FOCAS PMC range {Key} on {Host} rejected (0x{Status:X8}) — reading its {Count} tags individually",
+ plan.Key, device.Options.HostAddress, status, members.Count);
+ }
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
+ catch (OperationCanceledException)
+ {
+ FailMembers(plan, FocasStatusMapper.BadCommunicationError, references, results, now, ref served);
+ }
+ catch (Exception)
+ {
+ FailMembers(plan, FocasStatusMapper.BadCommunicationError, references, results, now, ref served);
+ }
+ }
+ }
+ return served;
+
+ void FailMembers(FocasPmcRangePlan plan, uint status, IReadOnlyList refs,
+ DataValueSnapshot[] res, DateTime stamp, ref bool[]? servedRef)
+ {
+ foreach (var m in plan.Members)
+ {
+ res[m.Index] = new DataValueSnapshot(null, status, null, stamp);
+ (servedRef ??= new bool[refs.Count])[m.Index] = true;
+ }
+ Volatile.Write(ref _health, new DriverHealth(DriverState.Degraded,
+ Volatile.Read(ref _health).LastSuccessfulRead,
+ $"FOCAS status 0x{status:X8} reading PMC range {plan.Key}"));
+ }
+ }
+
// ---- IWritable ----
///
@@ -1722,6 +1841,11 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// holding no value.
///
public ConcurrentDictionary LastFixedSnapshots { get; } = new(StringComparer.OrdinalIgnoreCase);
+ ///
+ /// PMC ranges () the CNC rejected as a merged read, so later polls skip
+ /// straight to per-tag reads instead of paying a doomed round-trip every cycle (#266).
+ ///
+ public ConcurrentDictionary CoalesceRejected { get; } = new(StringComparer.Ordinal);
/// Gets or sets the last cnc_statinfo snapshot (#257); null until the first answer.
public FocasStatus? LastStatus { get; set; }
/// Gets or sets the last program information snapshot.
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverFactoryExtensions.cs
index b6c70b01..1350a93b 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverFactoryExtensions.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverFactoryExtensions.cs
@@ -115,6 +115,7 @@ public static class FocasDriverFactoryExtensions
FixedTree = BuildFixedTree(dto.FixedTree),
AlarmProjection = BuildAlarmProjection(dto.AlarmProjection),
HandleRecycle = BuildHandleRecycle(dto.HandleRecycle),
+ PmcCoalesce = BuildPmcCoalesce(dto.PmcCoalesce),
};
return new FocasDriverBinding(options, BuildClientFactory(dto, driverInstanceId, loggerFactory));
@@ -206,6 +207,19 @@ public static class FocasDriverFactoryExtensions
};
}
+ /// Map the optional PmcCoalesce section; a missing section or key keeps the default.
+ private static FocasPmcCoalesceOptions BuildPmcCoalesce(FocasPmcCoalesceDto? dto)
+ {
+ var defaults = new FocasPmcCoalesceOptions();
+ if (dto is null) return defaults;
+ return new FocasPmcCoalesceOptions
+ {
+ Enabled = dto.Enabled ?? defaults.Enabled,
+ MaxSpanBytes = dto.MaxSpanBytes is > 0 ? dto.MaxSpanBytes.Value : defaults.MaxSpanBytes,
+ MaxGapBytes = dto.MaxGapBytes is >= 0 ? dto.MaxGapBytes.Value : defaults.MaxGapBytes,
+ };
+ }
+
private static FocasCncSeries ParseSeries(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return FocasCncSeries.Unknown;
@@ -284,6 +298,9 @@ public static class FocasDriverFactoryExtensions
/// Gets or sets the handle recycle configuration options.
public FocasHandleRecycleDto? HandleRecycle { get; init; }
+
+ /// Gets or sets the PMC range-coalescing configuration options.
+ public FocasPmcCoalesceDto? PmcCoalesce { get; init; }
}
internal sealed class FocasDeviceDto
@@ -348,6 +365,17 @@ public static class FocasDriverFactoryExtensions
public TimeSpan? PollInterval { get; init; }
}
+ /// Optional PmcCoalesce config section (#266).
+ internal sealed class FocasPmcCoalesceDto
+ {
+ /// Gets or sets a value indicating whether coalescing is on.
+ public bool? Enabled { get; init; }
+ /// Gets or sets the largest coalesced range in bytes.
+ public int? MaxSpanBytes { get; init; }
+ /// Gets or sets the largest tolerated gap in bytes.
+ public int? MaxGapBytes { get; init; }
+ }
+
/// Optional HandleRecycle config section.
internal sealed class FocasHandleRecycleDto
{
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasPmcCoalescer.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasPmcCoalescer.cs
new file mode 100644
index 00000000..0f5e17c1
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasPmcCoalescer.cs
@@ -0,0 +1,137 @@
+using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
+
+/// One PMC read the coalescer may merge — the caller's batch index plus the parsed address and type.
+/// Position in the caller's batch; results are sliced back to it.
+/// PMC area letter (R, X, ...), upper-case.
+/// Byte address.
+/// The tag's declared data type (decides slot width and decode).
+/// Bit index for a tag.
+internal readonly record struct FocasPmcReadItem(int Index, string Letter, int Number, FocasDataType Type, int? BitIndex);
+
+///
+/// One planned pmc_rdpmcrng read covering . is inclusive and is a
+/// whole number of -byte slots from .
+///
+internal sealed record FocasPmcRangePlan(
+ string Letter,
+ FocasDataType SlotType,
+ int SlotWidth,
+ int Start,
+ int End,
+ IReadOnlyList Members)
+{
+ /// Stable key for the "this range was rejected, read members individually" memo.
+ public string Key => $"{Letter}:{SlotType}:{Start}-{End}";
+
+ /// Slot index (into the decoded range) that holds 's value.
+ /// A member of this plan.
+ /// The zero-based slot index.
+ public int SlotOf(FocasPmcReadItem item) => (item.Number - Start) / SlotWidth;
+}
+
+///
+/// Merges adjacent / near PMC reads in one poll batch into fewer pmc_rdpmcrng calls (Gitea #266).
+///
+///
+/// Items are grouped by (letter, slot width class) and merged in address order while (a) the gap to
+/// the previous member is at most maxGapBytes and (b) the whole range stays within maxSpanBytes.
+/// A multi-byte member only joins a range when its offset from the range start is a multiple of its width — the
+/// CNC splits a range into width-sized slots from the start, so an unaligned member would be decoded from the
+/// wrong bytes. Members that cannot merge become single-member plans; the driver reads those through the
+/// ordinary per-tag path, so the common "one tag per area" case is byte-for-byte unchanged.
+/// Pure and deterministic: no I/O, no clock.
+///
+internal static class FocasPmcCoalescer
+{
+ /// Plans the reads for a batch.
+ /// The PMC reads in the batch (any order).
+ /// Largest range, in bytes, one coalesced read may cover (>= 1).
+ /// Largest run of unrequested bytes tolerated between two members (>= 0).
+ /// Range plans; every input item appears in exactly one plan.
+ public static IReadOnlyList Plan(
+ IEnumerable items, int maxSpanBytes, int maxGapBytes)
+ {
+ maxSpanBytes = Math.Max(1, maxSpanBytes);
+ maxGapBytes = Math.Max(0, maxGapBytes);
+ var plans = new List();
+
+ var groups = items
+ .GroupBy(i => (Letter: i.Letter.ToUpperInvariant(), Slot: SlotTypeOf(i.Type)))
+ .OrderBy(g => g.Key.Letter, StringComparer.Ordinal).ThenBy(g => g.Key.Slot);
+
+ foreach (var group in groups)
+ {
+ var width = FocasWireClient.PmcByteWidth((short)FocasPmcDataTypeLookup.FromFocasDataType(group.Key.Slot));
+ var sorted = group.OrderBy(i => i.Number).ThenBy(i => i.Index).ToList();
+
+ List? members = null;
+ var start = 0;
+ var end = 0; // inclusive
+
+ void Flush()
+ {
+ if (members is null) return;
+ // Round the range up to a whole number of slots so the CNC returns no partial trailing slot.
+ var bytes = end - start + 1;
+ var rounded = (bytes + width - 1) / width * width;
+ plans.Add(new FocasPmcRangePlan(group.Key.Letter, group.Key.Slot, width, start, start + rounded - 1, members));
+ members = null;
+ }
+
+ foreach (var item in sorted)
+ {
+ var itemEnd = item.Number + width - 1;
+ if (members is not null)
+ {
+ var gap = Math.Max(0, item.Number - (end + 1));
+ var aligned = (item.Number - start) % width == 0;
+ var newEnd = Math.Max(end, itemEnd);
+ // The span check is on the ROUNDED length a flush would produce.
+ var roundedSpan = (newEnd - start + 1 + width - 1) / width * width;
+ if (aligned && gap <= maxGapBytes && roundedSpan <= maxSpanBytes)
+ {
+ members.Add(item);
+ end = newEnd;
+ continue;
+ }
+ Flush();
+ }
+ members = [item];
+ start = item.Number;
+ end = itemEnd;
+ }
+ Flush();
+ }
+ return plans;
+ }
+
+ /// The data type whose wire width a read of uses (Bit and Byte share one).
+ /// The tag's declared type.
+ /// The representative slot type.
+ public static FocasDataType SlotTypeOf(FocasDataType type) => type switch
+ {
+ FocasDataType.Bit or FocasDataType.Byte => FocasDataType.Byte,
+ _ => type,
+ };
+
+ ///
+ /// Decodes one raw PMC slot (as pmc_rdpmcrng parsed it) into the tag's published value. The single
+ /// decode authority for both the per-tag and the coalesced path.
+ ///
+ /// The slot value.
+ /// The tag's declared type.
+ /// Bit index for a Bit tag.
+ /// The boxed value.
+ public static object MapSlot(long raw, FocasDataType type, int? bitIndex) => type switch
+ {
+ FocasDataType.Bit => (object)(((raw >> (bitIndex ?? 0)) & 1L) != 0),
+ FocasDataType.Byte => (object)(sbyte)(raw & 0xFFL),
+ FocasDataType.Int16 => (object)(short)raw,
+ FocasDataType.Int32 => (object)(int)raw,
+ FocasDataType.Float32 => (object)BitConverter.Int32BitsToSingle((int)raw),
+ FocasDataType.Float64 => (object)BitConverter.Int64BitsToDouble(raw),
+ _ => (object)raw,
+ };
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs
index ee339d7e..cdcecfea 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs
@@ -73,6 +73,21 @@ public interface IFocasClient : IDisposable
/// The list of currently active alarms; empty when none are active.
Task> ReadAlarmsAsync(CancellationToken cancellationToken);
+ ///
+ /// Read one PMC range (pmc_rdpmcrng) as raw slots: (end - start + 1) bytes split into
+ /// slots of 's width, from . The primitive behind PMC
+ /// range coalescing (Gitea #266) — the driver slices individual tag values out of the returned slots with
+ /// the same decode a per-tag read uses.
+ ///
+ /// PMC area letter.
+ /// Decides the slot width (Bit/Byte = 1, Int16 = 2, Int32/Float32 = 4, Float64 = 8).
+ /// First byte address.
+ /// Last byte address, inclusive; a whole number of slots from .
+ /// The cancellation token.
+ /// The raw slot values (null when not Good) and the mapped OPC UA status.
+ Task<(IReadOnlyList? slots, uint status)> ReadPmcSlotsAsync(
+ string letter, FocasDataType slotType, int start, int end, CancellationToken cancellationToken);
+
// ---- Fixed-tree T1 (identity + axis discovery + fast-poll dynamic bundle) ----
///
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/SynchronizedFocasClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/SynchronizedFocasClient.cs
index db53e176..9dc81fca 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/SynchronizedFocasClient.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/SynchronizedFocasClient.cs
@@ -61,6 +61,11 @@ public sealed class SynchronizedFocasClient : IFocasClient
FocasAddress address, FocasDataType type, CancellationToken cancellationToken) =>
RunBoundedAsync(ct => _inner.ReadAsync(address, type, ct), cancellationToken);
+ ///
+ public Task<(IReadOnlyList? slots, uint status)> ReadPmcSlotsAsync(
+ string letter, FocasDataType slotType, int start, int end, CancellationToken cancellationToken) =>
+ RunBoundedAsync(ct => _inner.ReadPmcSlotsAsync(letter, slotType, start, end, ct), cancellationToken);
+
///
public Task WriteAsync(
FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken) =>
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/WireFocasClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/WireFocasClient.cs
index 85296fcf..311d3cbd 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/WireFocasClient.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/WireFocasClient.cs
@@ -67,6 +67,30 @@ public sealed class WireFocasClient : IFocasClient
};
}
+ ///
+ public async Task<(IReadOnlyList? slots, uint status)> ReadPmcSlotsAsync(
+ string letter, FocasDataType slotType, int start, int end, CancellationToken cancellationToken)
+ {
+ if (!_wire.IsConnected) return (null, FocasStatusMapper.BadCommunicationError);
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var area = FocasPmcAreaLookup.FromLetter(letter);
+ if (area is null) return (null, FocasStatusMapper.BadNodeIdUnknown);
+ var dataType = FocasPmcDataTypeLookup.FromFocasDataType(slotType);
+ try
+ {
+ var result = await _wire.ReadPmcRangeAsync(area.Value, dataType, (ushort)start, (ushort)end, cancellationToken)
+ .ConfigureAwait(false);
+ if (!result.IsOk || result.Value is null)
+ return (null, FocasStatusMapper.MapFocasReturn(result.Rc));
+ return (result.Value.Values, FocasStatusMapper.Good);
+ }
+ catch (FocasWireException ex)
+ {
+ return (null, ex.Rc is short rc ? FocasStatusMapper.MapFocasReturn(rc) : FocasStatusMapper.BadCommunicationError);
+ }
+ }
+
///
public Task WriteAsync(
FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken)
@@ -325,17 +349,8 @@ public sealed class WireFocasClient : IFocasClient
return (null, FocasStatusMapper.MapFocasReturn(result.Rc));
var values = result.Value.Values;
if (values.Count == 0) return (null, FocasStatusMapper.BadOutOfRange);
- var raw = values[0];
- var mapped = type switch
- {
- FocasDataType.Bit => (object)(((long)raw >> (address.BitIndex ?? 0) & 1L) != 0),
- FocasDataType.Byte => (object)(sbyte)(raw & 0xFFL),
- FocasDataType.Int16 => (object)(short)raw,
- FocasDataType.Int32 => (object)(int)raw,
- FocasDataType.Float32 => (object)BitConverter.Int32BitsToSingle((int)raw),
- FocasDataType.Float64 => (object)BitConverter.Int64BitsToDouble(raw),
- _ => (object)raw,
- };
+ // One decode authority shared with the coalesced path (FocasPmcCoalescer.MapSlot).
+ var mapped = FocasPmcCoalescer.MapSlot(values[0], type, address.BitIndex);
return (mapped, FocasStatusMapper.Good);
}
catch (FocasWireException ex)
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendStatusAndPmcTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendStatusAndPmcTests.cs
index 80c6ed69..a981d0a4 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendStatusAndPmcTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendStatusAndPmcTests.cs
@@ -77,6 +77,96 @@ public sealed class WireBackendStatusAndPmcTests
}
}
+ ///
+ /// A poll batch of adjacent PMC bytes is served by ONE range read against the live wire, every tag gets
+ /// its own value, and a tag outside the merge window still reads through the per-tag path. Seeds are
+ /// byte-typed: the mock serves one entry per ADDRESS for every PMC type, which matches the real
+ /// byte-addressed range only for width 1.
+ ///
+ [Fact]
+ public async Task Adjacent_pmc_bytes_coalesce_into_one_range_read_via_wire_backend()
+ {
+ if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
+ var ct = TestContext.Current.CancellationToken;
+
+ await _fx.LoadProfileAsync("FWLIB64", ct);
+ await _fx.PatchStateAsync(new
+ {
+ pmc = new { R = new Dictionary
+ {
+ ["200"] = new { type = "byte", value = 17 },
+ ["201"] = new { type = "byte", value = 200 },
+ ["202"] = new { type = "byte", value = 0b0000_0100 },
+ ["203"] = new { type = "byte", value = 9 },
+ ["900"] = new { type = "byte", value = 33 },
+ }},
+ }, ct);
+
+ var counting = new CountingFactory();
+ var drv = new FocasDriver(new FocasDriverOptions
+ {
+ Devices = [new FocasDeviceOptions(DeviceHost)],
+ RawTags = FocasRawTags.Entries(
+ new FocasTagDefinition("R200", DeviceHost, "R200", FocasDataType.Byte, Writable: false),
+ new FocasTagDefinition("R201", DeviceHost, "R201", FocasDataType.Byte, Writable: false),
+ new FocasTagDefinition("R202b2", DeviceHost, "R202.2", FocasDataType.Bit, Writable: false),
+ new FocasTagDefinition("R203", DeviceHost, "R203", FocasDataType.Byte, Writable: false),
+ new FocasTagDefinition("R900", DeviceHost, "R900", FocasDataType.Byte, Writable: false)),
+ Probe = new FocasProbeOptions { Enabled = false },
+ }, driverInstanceId: "wire-pmc-coalesce", clientFactory: counting);
+
+ await using (drv)
+ {
+ await drv.InitializeAsync("{}", ct);
+ var snaps = await drv.ReadAsync(["R200", "R201", "R202b2", "R203", "R900"], ct);
+
+ snaps.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good);
+ Convert.ToInt32(snaps[0].Value).ShouldBe(17);
+ Convert.ToInt32(snaps[1].Value).ShouldBe(-56); // 200 as a signed byte
+ snaps[2].Value.ShouldBe(true);
+ Convert.ToInt32(snaps[3].Value).ShouldBe(9);
+ Convert.ToInt32(snaps[4].Value).ShouldBe(33);
+
+ counting.Client.ShouldNotBeNull();
+ counting.Client.RangeReads.ShouldBe(1, "R200..R203 merge into one pmc_rdpmcrng");
+ counting.Client.SingleReads.ShouldBe(1, "R900 is outside the gap window and reads on its own");
+ }
+ }
+
+ private sealed class CountingFactory : IFocasClientFactory
+ {
+ private readonly WireFocasClientFactory _inner = new();
+ public CountingClient? Client { get; private set; }
+ public void EnsureUsable() => _inner.EnsureUsable();
+ public IFocasClient Create() => Client = new CountingClient(_inner.Create());
+ }
+
+ private sealed class CountingClient(IFocasClient inner) : IFocasClient
+ {
+ public int RangeReads { get; private set; }
+ public int SingleReads { get; private set; }
+ public bool IsConnected => inner.IsConnected;
+ public Task ConnectAsync(FocasHostAddress a, TimeSpan t, CancellationToken ct) => inner.ConnectAsync(a, t, ct);
+ public Task<(object? value, uint status)> ReadAsync(FocasAddress a, FocasDataType t, CancellationToken ct)
+ { SingleReads++; return inner.ReadAsync(a, t, ct); }
+ public Task<(IReadOnlyList? slots, uint status)> ReadPmcSlotsAsync(string l, FocasDataType t, int s, int e, CancellationToken ct)
+ { RangeReads++; return inner.ReadPmcSlotsAsync(l, t, s, e, ct); }
+ public Task WriteAsync(FocasAddress a, FocasDataType t, object? v, CancellationToken ct) => inner.WriteAsync(a, t, v, ct);
+ public Task ProbeAsync(CancellationToken ct) => inner.ProbeAsync(ct);
+ public Task> ReadAlarmsAsync(CancellationToken ct) => inner.ReadAlarmsAsync(ct);
+ public Task GetSysInfoAsync(CancellationToken ct) => inner.GetSysInfoAsync(ct);
+ public Task> GetAxisNamesAsync(CancellationToken ct) => inner.GetAxisNamesAsync(ct);
+ public Task> GetSpindleNamesAsync(CancellationToken ct) => inner.GetSpindleNamesAsync(ct);
+ public Task ReadDynamicAsync(int i, CancellationToken ct) => inner.ReadDynamicAsync(i, ct);
+ public Task GetProgramInfoAsync(CancellationToken ct) => inner.GetProgramInfoAsync(ct);
+ public Task GetStatusAsync(CancellationToken ct) => inner.GetStatusAsync(ct);
+ public Task GetTimerAsync(FocasTimerKind k, CancellationToken ct) => inner.GetTimerAsync(k, ct);
+ public Task> GetServoLoadsAsync(CancellationToken ct) => inner.GetServoLoadsAsync(ct);
+ public Task> GetSpindleLoadsAsync(CancellationToken ct) => inner.GetSpindleLoadsAsync(ct);
+ public Task> GetSpindleMotorSpeedsAsync(CancellationToken ct) => inner.GetSpindleMotorSpeedsAsync(ct);
+ public void Dispose() => inner.Dispose();
+ }
+
private static async Task WaitFor(Func pred, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs
index d3197a69..268eee80 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs
@@ -56,6 +56,51 @@ internal class FakeFocasClient : IFocasClient
return Task.FromResult((value, status));
}
+ /// PMC byte memory the range read serves, keyed by (upper-case letter, byte address); absent = 0.
+ public Dictionary<(string Letter, int Address), byte> PmcMemory { get; } = new();
+ /// Gets the log of range reads issued (letter, slot type, start, end).
+ public List<(string Letter, FocasDataType SlotType, int Start, int End)> PmcRangeReads { get; } = new();
+ /// Ranges (letter, start, end) the fake CNC rejects with .
+ public List> PmcRangeRejectors { get; } = new();
+ /// The status a rejected range returns.
+ public uint PmcRangeRejectStatus { get; set; } = FocasStatusMapper.BadOutOfRange;
+ /// When true every range read throws (transport failure).
+ public bool ThrowOnPmcRange { get; set; }
+
+ /// Reads a PMC range as raw big-endian slots from .
+ /// PMC letter.
+ /// Slot type.
+ /// First byte.
+ /// Last byte, inclusive.
+ /// The cancellation token.
+ public virtual Task<(IReadOnlyList? slots, uint status)> ReadPmcSlotsAsync(
+ string letter, FocasDataType slotType, int start, int end, CancellationToken ct)
+ {
+ PmcRangeReads.Add((letter, slotType, start, end));
+ if (ThrowOnPmcRange) throw new InvalidOperationException("transport down");
+ if (PmcRangeRejectors.Any(r => r(letter, start, end)))
+ return Task.FromResult<(IReadOnlyList?, uint)>((null, PmcRangeRejectStatus));
+
+ var width = slotType switch
+ {
+ FocasDataType.Int16 => 2,
+ FocasDataType.Int32 or FocasDataType.Float32 => 4,
+ FocasDataType.Float64 => 8,
+ _ => 1,
+ };
+ var slots = new List();
+ for (var o = start; o + width - 1 <= end; o += width)
+ {
+ long v = 0;
+ for (var k = 0; k < width; k++)
+ v = (v << 8) | (PmcMemory.TryGetValue((letter, o + k), out var b) ? b : (byte)0);
+ // Sign-extend like the wire parser (Int16/Int32/Int64 big-endian); width 1 stays 0..255.
+ v = width switch { 2 => (short)v, 4 => (int)v, _ => v };
+ slots.Add(v);
+ }
+ return Task.FromResult<(IReadOnlyList?, uint)>((slots, FocasStatusMapper.Good));
+ }
+
/// Writes a value to a FOCAS address asynchronously.
/// The FOCAS address to write to.
/// The data type of the value.
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPmcCoalescerTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPmcCoalescerTests.cs
new file mode 100644
index 00000000..13e3b213
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPmcCoalescerTests.cs
@@ -0,0 +1,111 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
+
+/// Gitea #266 — the pure planner behind PMC range coalescing.
+[Trait("Category", "Unit")]
+public sealed class FocasPmcCoalescerTests
+{
+ private static FocasPmcReadItem B(int index, string letter, int number, FocasDataType t = FocasDataType.Byte, int? bit = null) =>
+ new(index, letter, number, t, bit);
+
+ [Fact]
+ public void Adjacent_bytes_merge_into_one_range()
+ {
+ var plans = FocasPmcCoalescer.Plan([B(0, "R", 100), B(1, "R", 101), B(2, "R", 102)], 128, 8);
+ var p = plans.ShouldHaveSingleItem();
+ (p.Letter, p.Start, p.End).ShouldBe(("R", 100, 102));
+ p.Members.Count.ShouldBe(3);
+ }
+
+ [Fact]
+ public void A_gap_within_the_threshold_merges_and_beyond_it_splits()
+ {
+ FocasPmcCoalescer.Plan([B(0, "R", 100), B(1, "R", 108)], 128, 8).Count.ShouldBe(1); // 7 byte gap
+ FocasPmcCoalescer.Plan([B(0, "R", 100), B(1, "R", 110)], 128, 8).Count.ShouldBe(2); // 9 byte gap
+ FocasPmcCoalescer.Plan([B(0, "R", 100), B(1, "R", 102)], 128, 0).Count.ShouldBe(2); // gap budget 0
+ FocasPmcCoalescer.Plan([B(0, "R", 100), B(1, "R", 101)], 128, 0).Count.ShouldBe(1); // adjacent still fine
+ }
+
+ [Fact]
+ public void The_span_cap_is_a_hard_limit_on_range_length()
+ {
+ var items = Enumerable.Range(0, 10).Select(i => B(i, "R", 100 + i)).ToList();
+ var plans = FocasPmcCoalescer.Plan(items, maxSpanBytes: 4, maxGapBytes: 8);
+ plans.Select(p => p.End - p.Start + 1).ShouldAllBe(len => len <= 4);
+ plans.Sum(p => p.Members.Count).ShouldBe(10);
+ plans.Count.ShouldBe(3); // 4 + 4 + 2
+ }
+
+ [Fact]
+ public void Different_letters_never_merge()
+ {
+ var plans = FocasPmcCoalescer.Plan([B(0, "R", 100), B(1, "D", 101), B(2, "R", 101)], 128, 8);
+ plans.Count.ShouldBe(2);
+ plans.Single(p => p.Letter == "R").Members.Count.ShouldBe(2);
+ plans.Single(p => p.Letter == "D").Members.Count.ShouldBe(1);
+ }
+
+ [Fact]
+ public void Bits_of_one_byte_share_a_slot_and_bits_and_bytes_share_a_range()
+ {
+ var plans = FocasPmcCoalescer.Plan(
+ [B(0, "X", 0, FocasDataType.Bit, 0), B(1, "X", 0, FocasDataType.Bit, 7), B(2, "X", 1)], 128, 8);
+ var p = plans.ShouldHaveSingleItem();
+ (p.Start, p.End).ShouldBe((0, 1));
+ p.SlotOf(p.Members.Single(m => m.Index == 2)).ShouldBe(1);
+ }
+
+ [Fact]
+ public void Words_merge_only_when_aligned_to_the_range_start_and_the_range_is_whole_slots()
+ {
+ // 100,102 aligned -> one range [100,103]; 105 is unaligned (offset 5) -> its own range.
+ var plans = FocasPmcCoalescer.Plan(
+ [B(0, "D", 100, FocasDataType.Int16), B(1, "D", 102, FocasDataType.Int16), B(2, "D", 105, FocasDataType.Int16)], 128, 8);
+ plans.Count.ShouldBe(2);
+ var first = plans.Single(p => p.Start == 100);
+ (first.End, first.SlotWidth, first.Members.Count).ShouldBe((103, 2, 2));
+ first.SlotOf(first.Members.Single(m => m.Number == 102)).ShouldBe(1);
+ plans.Single(p => p.Start == 105).Members.Count.ShouldBe(1);
+ }
+
+ [Fact]
+ public void A_word_range_with_a_gap_is_rounded_up_to_whole_slots()
+ {
+ // 100 (word) + 103 (word): offset 3 is unaligned -> split; 100 + 104 aligned, gap 2 -> merged, end 105.
+ var plans = FocasPmcCoalescer.Plan(
+ [B(0, "D", 100, FocasDataType.Int16), B(1, "D", 104, FocasDataType.Int16)], 128, 8);
+ var p = plans.ShouldHaveSingleItem();
+ (p.Start, p.End).ShouldBe((100, 105));
+ ((p.End - p.Start + 1) % p.SlotWidth).ShouldBe(0);
+ }
+
+ [Fact]
+ public void Different_widths_on_one_letter_do_not_merge()
+ {
+ FocasPmcCoalescer.Plan([B(0, "D", 100, FocasDataType.Byte), B(1, "D", 101, FocasDataType.Int16)], 128, 8)
+ .Count.ShouldBe(2);
+ }
+
+ [Fact]
+ public void Every_input_appears_in_exactly_one_plan_and_input_order_is_irrelevant()
+ {
+ var items = new[] { B(0, "R", 300), B(1, "R", 100), B(2, "R", 102), B(3, "R", 101), B(4, "R", 104), B(5, "R", 300) };
+ var a = FocasPmcCoalescer.Plan(items, 128, 8);
+ var b = FocasPmcCoalescer.Plan(items.Reverse(), 128, 8);
+ a.SelectMany(p => p.Members).Select(m => m.Index).OrderBy(x => x).ShouldBe([0, 1, 2, 3, 4, 5]);
+ a.Select(p => (p.Start, p.End)).ShouldBe(b.Select(p => (p.Start, p.End)));
+ }
+
+ [Fact]
+ public void Slot_decode_matches_the_per_tag_mapping()
+ {
+ FocasPmcCoalescer.MapSlot(0b0000_0100, FocasDataType.Bit, 2).ShouldBe(true);
+ FocasPmcCoalescer.MapSlot(0b0000_0100, FocasDataType.Bit, 3).ShouldBe(false);
+ FocasPmcCoalescer.MapSlot(0xFF, FocasDataType.Byte, null).ShouldBe((sbyte)-1);
+ FocasPmcCoalescer.MapSlot(-2, FocasDataType.Int16, null).ShouldBe((short)-2);
+ FocasPmcCoalescer.MapSlot(BitConverter.SingleToInt32Bits(1.5f), FocasDataType.Float32, null).ShouldBe(1.5f);
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPmcCoalescingDriverTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPmcCoalescingDriverTests.cs
new file mode 100644
index 00000000..6d26c83e
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPmcCoalescingDriverTests.cs
@@ -0,0 +1,181 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
+
+/// Gitea #266 — driver-level behaviour of PMC range coalescing in a poll batch.
+[Trait("Category", "Unit")]
+public sealed class FocasPmcCoalescingDriverTests
+{
+ private const string Host = "focas://10.0.0.5:8193";
+
+ private static FocasTagDefinition Tag(string name, string addr, FocasDataType t) =>
+ new(name, Host, addr, t, Writable: false);
+
+ private static async Task<(FocasDriver drv, FakeFocasClient client)> StartAsync(
+ FocasPmcCoalesceOptions? opts, params FocasTagDefinition[] tags)
+ {
+ var client = new FakeFocasClient();
+ var drv = new FocasDriver(new FocasDriverOptions
+ {
+ Devices = [new FocasDeviceOptions(Host)],
+ Probe = new FocasProbeOptions { Enabled = false },
+ RawTags = FocasRawTags.Entries(tags),
+ PmcCoalesce = opts ?? new FocasPmcCoalesceOptions(),
+ }, "drv-1", new FakeFocasClientFactory { Customise = () => client });
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ return (drv, client);
+ }
+
+ [Fact]
+ public async Task Adjacent_pmc_tags_are_served_by_one_range_read_and_sliced_back_per_tag()
+ {
+ var (drv, c) = await StartAsync(null,
+ Tag("A", "R100", FocasDataType.Byte),
+ Tag("B", "R101", FocasDataType.Byte),
+ Tag("Bit", "R102.3", FocasDataType.Bit),
+ Tag("Far", "R900", FocasDataType.Byte));
+ c.PmcMemory[("R", 100)] = 0x11;
+ c.PmcMemory[("R", 101)] = 0xFF;
+ c.PmcMemory[("R", 102)] = 0b0000_1000;
+ c.Values["R900"] = (sbyte)7;
+
+ var r = await drv.ReadAsync(["A", "B", "Bit", "Far"], CancellationToken.None);
+
+ c.PmcRangeReads.ShouldBe([("R", FocasDataType.Byte, 100, 102)]);
+ r[0].Value.ShouldBe((sbyte)0x11);
+ r[1].Value.ShouldBe((sbyte)-1);
+ r[2].Value.ShouldBe(true);
+ r[3].Value.ShouldBe((sbyte)7); // lone tag: ordinary per-tag path, no range read of its own
+ r.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good);
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+
+ [Fact]
+ public async Task Aligned_words_decode_from_their_own_slot()
+ {
+ var (drv, c) = await StartAsync(null,
+ Tag("W0", "D200", FocasDataType.Int16),
+ Tag("W1", "D202", FocasDataType.Int16));
+ c.PmcMemory[("D", 200)] = 0x01; c.PmcMemory[("D", 201)] = 0x02; // 0x0102 = 258
+ c.PmcMemory[("D", 202)] = 0xFF; c.PmcMemory[("D", 203)] = 0xFE; // -2
+
+ var r = await drv.ReadAsync(["W0", "W1"], CancellationToken.None);
+
+ c.PmcRangeReads.ShouldBe([("D", FocasDataType.Int16, 200, 203)]);
+ r[0].Value.ShouldBe((short)258);
+ r[1].Value.ShouldBe((short)-2);
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+
+ [Fact]
+ public async Task A_single_pmc_tag_never_issues_a_range_read()
+ {
+ var (drv, c) = await StartAsync(null, Tag("A", "R100", FocasDataType.Byte));
+ c.Values["R100"] = (sbyte)5;
+
+ var r = await drv.ReadAsync(["A"], CancellationToken.None);
+
+ c.PmcRangeReads.ShouldBeEmpty();
+ r[0].Value.ShouldBe((sbyte)5);
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+
+ [Fact]
+ public async Task Disabling_coalescing_restores_per_tag_reads()
+ {
+ var (drv, c) = await StartAsync(new FocasPmcCoalesceOptions { Enabled = false },
+ Tag("A", "R100", FocasDataType.Byte), Tag("B", "R101", FocasDataType.Byte));
+ c.Values["R100"] = (sbyte)1; c.Values["R101"] = (sbyte)2;
+
+ var r = await drv.ReadAsync(["A", "B"], CancellationToken.None);
+
+ c.PmcRangeReads.ShouldBeEmpty();
+ r[0].Value.ShouldBe((sbyte)1);
+ r[1].Value.ShouldBe((sbyte)2);
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+
+ [Fact]
+ public async Task The_span_cap_and_gap_threshold_from_options_are_honoured()
+ {
+ var (drv, c) = await StartAsync(new FocasPmcCoalesceOptions { MaxSpanBytes = 2, MaxGapBytes = 0 },
+ Tag("A", "R100", FocasDataType.Byte), Tag("B", "R101", FocasDataType.Byte), Tag("C", "R102", FocasDataType.Byte));
+ c.Values["R102"] = (sbyte)9;
+
+ var r = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None);
+
+ c.PmcRangeReads.ShouldBe([("R", FocasDataType.Byte, 100, 101)]); // C is alone -> per-tag
+ r[2].Value.ShouldBe((sbyte)9);
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+
+ [Fact]
+ public async Task A_range_the_cnc_rejects_falls_back_to_per_tag_reads_with_their_own_status_and_is_remembered()
+ {
+ var (drv, c) = await StartAsync(null,
+ Tag("A", "R100", FocasDataType.Byte), Tag("B", "R106", FocasDataType.Byte));
+ c.PmcRangeRejectors.Add((_, s, e) => e - s + 1 > 2); // the spare gap bytes are not valid addresses
+ c.Values["R100"] = (sbyte)1;
+ c.Values["R106"] = (sbyte)2;
+ c.ReadStatuses["R106"] = FocasStatusMapper.BadNodeIdUnknown; // B is genuinely bad; A must NOT inherit that
+
+ var r1 = await drv.ReadAsync(["A", "B"], CancellationToken.None);
+ r1[0].StatusCode.ShouldBe(FocasStatusMapper.Good);
+ r1[0].Value.ShouldBe((sbyte)1);
+ r1[1].StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
+ c.PmcRangeReads.Count.ShouldBe(1);
+
+ await drv.ReadAsync(["A", "B"], CancellationToken.None);
+ c.PmcRangeReads.Count.ShouldBe(1, "a rejected range is remembered; later polls go straight to per-tag reads");
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+
+ [Fact]
+ public async Task A_transport_failure_fails_every_member_without_per_tag_retries()
+ {
+ var (drv, c) = await StartAsync(null,
+ Tag("A", "R100", FocasDataType.Byte), Tag("B", "R101", FocasDataType.Byte));
+ c.ThrowOnPmcRange = true;
+ c.Values["R100"] = (sbyte)1; // would read Good if the driver wrongly fell back
+
+ var r = await drv.ReadAsync(["A", "B"], CancellationToken.None);
+
+ r.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.BadCommunicationError);
+ r.ShouldAllBe(s => s.Value == null);
+ c.PmcRangeReads.Count.ShouldBe(1);
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+
+ [Fact]
+ public async Task Result_order_follows_the_request_when_pmc_and_non_pmc_tags_are_mixed()
+ {
+ var (drv, c) = await StartAsync(null,
+ Tag("P", "PARAM:6711", FocasDataType.Int32),
+ Tag("A", "R100", FocasDataType.Byte),
+ Tag("B", "R101", FocasDataType.Byte));
+ c.Values["PARAM:6711"] = 1234;
+ c.PmcMemory[("R", 100)] = 3; c.PmcMemory[("R", 101)] = 4;
+
+ var r = await drv.ReadAsync(["B", "P", "A"], CancellationToken.None);
+
+ r[0].Value.ShouldBe((sbyte)4);
+ r[1].Value.ShouldBe(1234);
+ r[2].Value.ShouldBe((sbyte)3);
+ c.PmcRangeReads.Count.ShouldBe(1);
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+
+ [Fact]
+ public async Task Duplicate_references_in_one_batch_each_get_their_value()
+ {
+ var (drv, c) = await StartAsync(null, Tag("A", "R100", FocasDataType.Byte), Tag("B", "R101", FocasDataType.Byte));
+ c.PmcMemory[("R", 100)] = 3; c.PmcMemory[("R", 101)] = 4;
+
+ var r = await drv.ReadAsync(["A", "B", "A"], CancellationToken.None);
+
+ r.Select(s => s.Value).ShouldBe([(object)(sbyte)3, (sbyte)4, (sbyte)3]);
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+}