From 5b36c717de3523bb3d2b0b40fc6a0f69a42d237a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 8 Oct 2026 16:53:44 -0400 Subject: [PATCH 1/4] feat(focas): cnc_statinfo status flags as Status/ fixed-tree nodes (#257) Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe --- .../FocasDriver.cs | 45 ++++- .../IFocasClient.cs | 43 +++++ .../SynchronizedFocasClient.cs | 4 + .../Wire/WireFocasClient.cs | 12 ++ .../Series/WireBackendStatusAndPmcTests.cs | 89 +++++++++ .../FakeFocasClient.cs | 15 ++ .../FocasStatusFlagsTests.cs | 173 ++++++++++++++++++ 7 files changed, 379 insertions(+), 2 deletions(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendStatusAndPmcTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasStatusFlagsTests.cs 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 7152be71..0c0be6f7 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs @@ -751,6 +751,15 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, EmitFixedVariable(opMode, "OperationMode", "ModeText", DriverDataType.String); } + // Fixed-tree T2.5 — Status subtree (cnc_statinfo flags, #257), gated on its own probe. + if (cache.Capabilities.Status) + { + var status = deviceFolder.Folder("Status", "Status"); + foreach (var field in StatusFields) + EmitFixedVariable(status, "Status", field, DriverDataType.Int32); + EmitFixedVariable(status, "Status", "RunText", DriverDataType.String); + } + // Fixed-tree T3 — Timers subtree (power-on / operating / cutting / cycle). if (cache.Capabilities.Timers) { @@ -1008,6 +1017,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, var servoLoads = await SafeProbe(() => client.GetServoLoadsAsync(ct), []); var programInfo = await SafeTryProbe(() => client.GetProgramInfoAsync(ct)); var timer = await SafeTryProbe(() => client.GetTimerAsync(FocasTimerKind.PowerOn, ct)); + var statusProbe = await SafeTryProbe(() => client.GetStatusAsync(ct)); var spindleLoad = await SafeProbe(() => client.GetSpindleLoadsAsync(ct), []); var caps = new FocasFixedTreeCapabilities( @@ -1016,7 +1026,9 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, SpindleMotorSpeed: spindleMotorSpeeds.Count > 0, ServoLoad: servoLoads.Count > 0, ProgramInfo: programInfo is not null, - Timers: timer is not null); + Timers: timer is not null, + Status: statusProbe is not null); + state.LastStatus = statusProbe; state.FixedTreeCache = new FocasFixedTreeCache(sys, [.. axes], [.. spindles], caps); // The bootstrap IS an answer from the device, and Identity comes entirely from it — @@ -1054,6 +1066,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, var programPollDue = DateTime.MinValue; var timerPollDue = DateTime.MinValue; + var statusPollDue = DateTime.MinValue; while (!ct.IsCancellationRequested) { try @@ -1129,6 +1142,20 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, programPollDue = DateTime.UtcNow + programInterval; } + // Status flags (cnc_statinfo, #257) — same cadence as program/mode: run/emergency/alarm + // transitions are operator-timescale events and one call returns all of them. + if (cache.Capabilities.Status + && programInterval > TimeSpan.Zero && DateTime.UtcNow >= statusPollDue) + { + try { state.LastStatus = await client.GetStatusAsync(ct).ConfigureAwait(false); } + catch (Exception ex) + { + /* transient — keep the last value; staleness is dated by LastFixedPollUtc */ + _logger.LogDebug(ex, "FOCAS status poll failed for {Host}", state.Options.HostAddress); + } + statusPollDue = DateTime.UtcNow + programInterval; + } + // Timers — slowest cadence. Fires 4 FWLIB calls per tick (one per kind). var timerInterval = _options.FixedTree.TimerPollInterval; if (cache.Capabilities.Timers @@ -1245,6 +1272,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, } } + private static readonly string[] StatusFields = ["Auto", "Run", "Motion", "Mstb", "Emergency", "Alarm", "Edit", "TmMode"]; + private static object? TimerValue(DeviceState state, FocasTimerKind kind) => state.LastTimers.TryGetValue(kind, out var t) ? (object)t.TotalSeconds : null; @@ -1385,6 +1414,15 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, "OperationMode/Mode" => state.LastProgramInfo?.Mode, "OperationMode/ModeText" => state.LastProgramInfo?.Mode is { } mode ? FocasOpMode.ToText(mode) : null, + "Status/Auto" => state.LastStatus?.Auto, + "Status/Run" => state.LastStatus?.Run, + "Status/Motion" => state.LastStatus?.Motion, + "Status/Mstb" => state.LastStatus?.Mstb, + "Status/Emergency" => state.LastStatus?.Emergency, + "Status/Alarm" => state.LastStatus?.Alarm, + "Status/Edit" => state.LastStatus?.Edit, + "Status/TmMode" => state.LastStatus?.TmMode, + "Status/RunText" => state.LastStatus is { } st ? FocasRunState.ToText(st.Run) : null, "Timers/PowerOnSeconds" => TimerValue(state, FocasTimerKind.PowerOn), "Timers/OperatingSeconds" => TimerValue(state, FocasTimerKind.Operating), "Timers/CuttingSeconds" => TimerValue(state, FocasTimerKind.Cutting), @@ -1618,7 +1656,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, bool SpindleMotorSpeed, // cnc_rdspmeter type 1 (motor speed) bootstrap probe succeeded bool ServoLoad, // cnc_rdsvmeter bootstrap probe returned data bool ProgramInfo, // cnc_exeprgname2 + cnc_rdblkcount + cnc_rdopmode work - bool Timers); // cnc_rdtimer works for at least PowerOn + bool Timers, // cnc_rdtimer works for at least PowerOn + bool Status = false); // cnc_statinfo answered at bootstrap (#257) internal sealed class DeviceState(FocasHostAddress parsedAddress, FocasDeviceOptions options) { @@ -1683,6 +1722,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, /// holding no value. /// public ConcurrentDictionary LastFixedSnapshots { get; } = new(StringComparer.OrdinalIgnoreCase); + /// 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. public FocasProgramInfo? LastProgramInfo { get; set; } /// Gets or sets the cached first-axis dynamic snapshot — feeds Program/Number, /MainNumber, /Sequence. 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 2d5d8725..ee339d7e 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs @@ -125,6 +125,17 @@ public interface IFocasClient : IDisposable /// The aggregate program + operation-mode snapshot. Task GetProgramInfoAsync(CancellationToken cancellationToken); + // ---- Fixed-tree T2.5 (CNC status flags, Gitea #257) ---- + + /// + /// Read the CNC status flags via cnc_statinfo (ODBST). Populates the + /// Status/* subtree. Throws when the CNC refuses the call, which the driver's + /// bootstrap probe reads as "this series has no status block" and suppresses the subtree. + /// + /// The cancellation token. + /// The CNC status flag snapshot. + Task GetStatusAsync(CancellationToken cancellationToken); + // ---- Fixed-tree T3 (timers) ---- /// @@ -172,6 +183,38 @@ public interface IFocasClient : IDisposable Task> GetSpindleMotorSpeedsAsync(CancellationToken cancellationToken); } +/// +/// CNC status flags from cnc_statinfo (ODBST) — raw FANUC codes, published verbatim as +/// Int32 so a client sees exactly what the control reported. additionally has a +/// text projection (). +/// +/// Automatic-operation mode code. +/// Automatic-operation state (0 RESET, 1 STOP, 2 HOLD, 3 START, 4 MSTR). +/// Axis motion (0 none, 1 motion, 2 dwell). +/// M/S/T/B function state (0 none, 1 executing). +/// Emergency stop (0 not, 1 emergency, 2 reset). +/// Alarm state (0 none, 1 alarm, 2 battery-low, 3 fan). +/// Program-edit state. +/// T/M mode as reported by the control. +public sealed record FocasStatus(int Auto, int Run, int Motion, int Mstb, int Emergency, int Alarm, int Edit, int TmMode); + +/// Human-readable text for . +public static class FocasRunState +{ + /// Converts the ODBST.run code to its operator-facing label. + /// The run-state code. + /// The label, or Run{n} for a code outside the documented set. + public static string ToText(int run) => run switch + { + 0 => "RESET", + 1 => "STOP", + 2 => "HOLD", + 3 => "START", + 4 => "MSTR", + _ => $"Run{run}", + }; +} + /// /// One servo-meter entry — one axis's current load percentage; null when the CNC's record /// described no usable value (never a fabricated 0). 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 e2a3f759..db53e176 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/SynchronizedFocasClient.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/SynchronizedFocasClient.cs @@ -90,6 +90,10 @@ public sealed class SynchronizedFocasClient : IFocasClient public Task GetProgramInfoAsync(CancellationToken cancellationToken) => RunBoundedAsync(ct => _inner.GetProgramInfoAsync(ct), cancellationToken); + /// + public Task GetStatusAsync(CancellationToken cancellationToken) => + RunBoundedAsync(ct => _inner.GetStatusAsync(ct), cancellationToken); + /// public Task GetTimerAsync(FocasTimerKind kind, CancellationToken cancellationToken) => RunBoundedAsync(ct => _inner.GetTimerAsync(kind, ct), 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 acc104c1..85296fcf 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 @@ -129,6 +129,18 @@ public sealed class WireFocasClient : IFocasClient AxesCount: axesCount); } + /// + public async Task GetStatusAsync(CancellationToken cancellationToken) + { + RequireConnected(); + var result = await _wire.ReadStatusAsync(cancellationToken).ConfigureAwait(false); + ThrowIfRcNonZero(result.Rc, "cnc_statinfo", result.IsOk); + return MapStatus(result.Value!); + } + + internal static FocasStatus MapStatus(WireStatus s) => + new(s.Auto, s.Run, s.Motion, s.Mstb, s.Emergency, s.Alarm, s.Edit, s.TmMode); + /// public async Task> GetAxisNamesAsync(CancellationToken cancellationToken) { 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 new file mode 100644 index 00000000..80c6ed69 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendStatusAndPmcTests.cs @@ -0,0 +1,89 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests.Series; + +/// +/// Live-wire coverage (focas-mock) for #257 (cnc_statinfo status flags) and #266 (PMC +/// range coalescing). Skips cleanly when OTOPCUA_FOCAS_SIM_ENDPOINT (default +/// localhost:8193) is unreachable. Read-only: no write path is exercised. +/// +[Collection(FocasSimCollection.Name)] +public sealed class WireBackendStatusAndPmcTests +{ + private readonly FocasSimFixture _fx; + + /// Initializes a new instance of the class. + /// The FOCAS simulator fixture. + public WireBackendStatusAndPmcTests(FocasSimFixture fx) => _fx = fx; + + private string DeviceHost => _fx.DeviceUri; + + /// The mock's cnc_statinfo flags surface through the Status/ subtree and an authored FIXED: tag. + [Fact] + public async Task Status_flags_populate_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 + { + sysinfo = new + { + addinfo = 0, max_axis = 8, cnc_type = "M ", mt_type = "M ", + series = "30i ", version = "A1.0", axes = "1 ", + }, + axis_names = new[] { "X" }, + statinfo = new { aut = 1, run = 3, motion = 1, mstb = 0, emergency = 0, alarm = 1, edit = 0 }, + }, ct); + + var driver = new FocasDriver(new FocasDriverOptions + { + Devices = [new FocasDeviceOptions(DeviceHost)], + RawTags = FocasRawTags.Entries( + new FocasTagDefinition("RunFlag", DeviceHost, "FIXED:Status/Run", FocasDataType.Int32, Writable: false)), + Probe = new FocasProbeOptions { Enabled = false }, + FixedTree = new FocasFixedTreeOptions + { + Enabled = true, + PollInterval = TimeSpan.FromMilliseconds(100), + ProgramPollInterval = TimeSpan.FromMilliseconds(200), + }, + }, driverInstanceId: "focas-wire-status", clientFactory: new WireFocasClientFactory()); + + await using (driver) + { + await driver.InitializeAsync("{}", ct); + await WaitFor(() => driver.GetDeviceState(DeviceHost) is { LastStatus: not null }, TimeSpan.FromSeconds(5)); + + var state = driver.GetDeviceState(DeviceHost); + state.ShouldNotBeNull(); + state.LastStatus.ShouldNotBeNull(); + state.LastStatus.Run.ShouldBe(3); + state.LastStatus.Alarm.ShouldBe(1); + + var snaps = await driver.ReadAsync(["RunFlag", $"{DeviceHost}/Status/Alarm", $"{DeviceHost}/Status/RunText"], ct); + snaps.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good); + snaps[0].Value.ShouldBe(3); + snaps[1].Value.ShouldBe(1); + snaps[2].Value.ShouldBe("START"); + + // The flags follow the machine. + await _fx.PatchStateAsync(new { statinfo = new { run = 1, emergency = 1 } }, ct); + await WaitFor(() => driver.GetDeviceState(DeviceHost) is { LastStatus.Run: 1 }, TimeSpan.FromSeconds(5)); + driver.GetDeviceState(DeviceHost)!.LastStatus!.Emergency.ShouldBe(1); + } + } + + private static async Task WaitFor(Func pred, TimeSpan timeout) + { + var deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + if (pred()) return; + await Task.Delay(50); + } + } +} 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 ce8671e6..d3197a69 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 @@ -121,6 +121,21 @@ internal class FakeFocasClient : IFocasClient public virtual Task GetProgramInfoAsync(CancellationToken ct) => Task.FromResult(ProgramInfo); + /// Gets or sets the cnc_statinfo status snapshot. + public FocasStatus Status { get; set; } = new(1, 3, 1, 0, 0, 0, 0, 0); + /// Gets or sets a value indicating whether throws (older series / refused call). + public bool ThrowOnStatus { get; set; } + /// Gets the number of status reads served. + public int StatusReads { get; private set; } + /// Gets the CNC status flags asynchronously. + /// The cancellation token. + public virtual Task GetStatusAsync(CancellationToken ct) + { + StatusReads++; + if (ThrowOnStatus) throw new InvalidOperationException("cnc_statinfo refused"); + return Task.FromResult(Status); + } + /// Gets the dictionary of timers keyed by timer kind. public Dictionary Timers { get; } = []; /// Gets timer data asynchronously. diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasStatusFlagsTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasStatusFlagsTests.cs new file mode 100644 index 00000000..8ee12585 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasStatusFlagsTests.cs @@ -0,0 +1,173 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; + +/// +/// Gitea #257 — cnc_statinfo (Auto / Run / Motion / Mstb / Emergency / Alarm / Edit / TmMode) +/// surfaces as a read-only Status/ fixed-tree subtree, authorable as FIXED:Status/Run. +/// The wire client already decoded the call (FocasWireClient.ReadStatusAsync) but nothing consumed it. +/// +[Trait("Category", "Unit")] +public sealed class FocasStatusFlagsTests +{ + private const string Host = "focas://10.0.0.5:8193"; + + private static FocasDriver NewDriver(FakeFocasClient client, params FocasTagDefinition[] tags) => + new(new FocasDriverOptions + { + Devices = [new FocasDeviceOptions(Host)], + Probe = new FocasProbeOptions { Enabled = false }, + RawTags = FocasRawTags.Entries(tags), + FixedTree = new FocasFixedTreeOptions + { + Enabled = true, + PollInterval = TimeSpan.FromMilliseconds(10), + ProgramPollInterval = TimeSpan.FromMilliseconds(10), + }, + }, "drv-1", new FakeFocasClientFactory { Customise = () => client }); + + private static async Task WaitAsync(Func predicate) + { + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5); + while (DateTime.UtcNow < deadline && !predicate()) await Task.Delay(15); + predicate().ShouldBeTrue("condition never became true"); + } + + [Fact] + public void The_wire_status_block_maps_field_for_field() + { + var s = Wire.WireFocasClient.MapStatus(new Wire.WireStatus(Auto: 1, Run: 2, Motion: 3, Mstb: 4, Emergency: 5, Alarm: 6, Edit: 7, TmMode: 8)); + s.ShouldBe(new FocasStatus(1, 2, 3, 4, 5, 6, 7, 8)); + } + + [Theory] + [InlineData(0, "RESET")] + [InlineData(1, "STOP")] + [InlineData(2, "HOLD")] + [InlineData(3, "START")] + [InlineData(4, "MSTR")] + [InlineData(9, "Run9")] + public void Run_state_text(int run, string text) => FocasRunState.ToText(run).ShouldBe(text); + + [Fact] + public async Task Status_flags_read_good_through_the_fixed_tree_references() + { + var client = new FakeFocasClient { Status = new FocasStatus(Auto: 1, Run: 3, Motion: 1, Mstb: 2, Emergency: 0, Alarm: 1, Edit: 0, TmMode: 4) }; + var drv = NewDriver(client); + await drv.InitializeAsync("{}", CancellationToken.None); + await WaitAsync(() => drv.GetDeviceState(Host)?.LastStatus is not null); + + string[] fields = ["Auto", "Run", "Motion", "Mstb", "Emergency", "Alarm", "Edit", "TmMode"]; + int[] expected = [1, 3, 1, 2, 0, 1, 0, 4]; + var read = await drv.ReadAsync([.. fields.Select(f => $"{Host}/Status/{f}")], CancellationToken.None); + for (var i = 0; i < fields.Length; i++) + { + read[i].StatusCode.ShouldBe(FocasStatusMapper.Good, fields[i]); + read[i].Value.ShouldBe(expected[i], fields[i]); + } + + var text = await drv.ReadAsync([$"{Host}/Status/RunText"], CancellationToken.None); + text[0].Value.ShouldBe("START"); + + await drv.ShutdownAsync(CancellationToken.None); + } + + [Fact] + public async Task An_authored_FIXED_Status_tag_reads_the_flag() + { + var client = new FakeFocasClient { Status = new FocasStatus(1, 3, 1, 0, 1, 0, 0, 0) }; + var drv = NewDriver(client, + new FocasTagDefinition("EStop", Host, "FIXED:Status/Emergency", FocasDataType.Int32, Writable: false)); + await drv.InitializeAsync("{}", CancellationToken.None); + await WaitAsync(() => drv.GetDeviceState(Host)?.LastStatus is not null); + + var snap = (await drv.ReadAsync(["EStop"], CancellationToken.None))[0]; + snap.StatusCode.ShouldBe(FocasStatusMapper.Good); + snap.Value.ShouldBe(1); + + await drv.ShutdownAsync(CancellationToken.None); + } + + [Fact] + public async Task Status_updates_follow_the_machine() + { + var client = new FakeFocasClient { Status = new FocasStatus(1, 0, 0, 0, 0, 0, 0, 0) }; + var drv = NewDriver(client); + await drv.InitializeAsync("{}", CancellationToken.None); + await WaitAsync(() => drv.GetDeviceState(Host)?.LastStatus is not null); + + client.Status = new FocasStatus(1, 3, 1, 0, 0, 0, 0, 0); + await WaitAsync(() => drv.GetDeviceState(Host)?.LastStatus?.Run == 3); + (await drv.ReadAsync([$"{Host}/Status/Run"], CancellationToken.None))[0].Value.ShouldBe(3); + + await drv.ShutdownAsync(CancellationToken.None); + } + + [Fact] + public async Task A_cnc_that_refuses_statinfo_gets_no_Status_node_and_the_rest_of_the_tree_survives() + { + var client = new FakeFocasClient { ThrowOnStatus = true }; + var drv = NewDriver(client); + await drv.InitializeAsync("{}", CancellationToken.None); + await WaitAsync(() => drv.GetDeviceState(Host)?.FixedTreeCache is not null); + + var builder = new RecordingBuilder(); + await drv.DiscoverAsync(builder, CancellationToken.None); + + builder.Folders.ShouldNotContain(f => f.BrowseName == "Status"); + builder.Variables.ShouldContain(v => v.Info.FullName == "FIXED:Identity/SeriesNumber"); + + await drv.ShutdownAsync(CancellationToken.None); + } + + [Fact] + public async Task Discovery_emits_every_status_leaf_with_a_parseable_read_only_address() + { + var drv = NewDriver(new FakeFocasClient()); + await drv.InitializeAsync("{}", CancellationToken.None); + await WaitAsync(() => drv.GetDeviceState(Host)?.FixedTreeCache is not null); + + var builder = new RecordingBuilder(); + await drv.DiscoverAsync(builder, CancellationToken.None); + + builder.Folders.ShouldContain(f => f.BrowseName == "Status"); + var leaves = builder.Variables.Where(v => v.Info.FullName.StartsWith("FIXED:Status/", StringComparison.Ordinal)).ToList(); + leaves.Select(l => l.BrowseName).ShouldBe( + ["Auto", "Run", "Motion", "Mstb", "Emergency", "Alarm", "Edit", "TmMode", "RunText"], ignoreOrder: true); + foreach (var (_, info) in leaves) + { + FocasAddress.TryParse(info.FullName).ShouldNotBeNull(); + info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly); + } + + await drv.ShutdownAsync(CancellationToken.None); + } + + private sealed class RecordingBuilder : IAddressSpaceBuilder + { + public List<(string BrowseName, string DisplayName)> Folders { get; } = []; + public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = []; + + public IAddressSpaceBuilder Folder(string browseName, string displayName) + { Folders.Add((browseName, displayName)); return this; } + + public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info) + { Variables.Add((browseName, info)); return new Handle(info.FullName); } + + public void AddProperty(string name, DriverDataType type, object? value) { } + + private sealed class Handle(string fullRef) : IVariableHandle + { + public string FullReference => fullRef; + public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink(); + } + + private sealed class NullSink : IAlarmConditionSink + { + public void OnTransition(AlarmEventArgs args) { } + } + } +} From 8d916f2af7cbef9fddd8d8cb46d819d293d1e35e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 8 Oct 2026 16:59:07 -0400 Subject: [PATCH 2/4] feat(focas): coalesce adjacent PMC reads into one pmc_rdpmcrng (#266) Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe --- .../FocasDriverOptions.cs | 28 +++ .../FocasDriver.cs | 124 ++++++++++++ .../FocasDriverFactoryExtensions.cs | 28 +++ .../FocasPmcCoalescer.cs | 137 +++++++++++++ .../IFocasClient.cs | 15 ++ .../SynchronizedFocasClient.cs | 5 + .../Wire/WireFocasClient.cs | 37 ++-- .../Series/WireBackendStatusAndPmcTests.cs | 90 +++++++++ .../FakeFocasClient.cs | 45 +++++ .../FocasPmcCoalescerTests.cs | 111 +++++++++++ .../FocasPmcCoalescingDriverTests.cs | 181 ++++++++++++++++++ 11 files changed, 790 insertions(+), 11 deletions(-) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasPmcCoalescer.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPmcCoalescerTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPmcCoalescingDriverTests.cs 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); + } +} From 487d394cde079cd2a34246fc467c4c44148fce65 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 8 Oct 2026 16:59:08 -0400 Subject: [PATCH 3/4] docs(focas): Status/ flags, PMC coalescing, parts count via PARAM:6711, read-only policy (#257 #266 #258) Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe --- CLAUDE.md | 3 ++ docs/drivers/FOCAS.md | 77 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1c68f757..2eb411c4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1105,6 +1105,9 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa primary-host claim backs off 2 s → 30 s; writes encode the birth's own datatype (unsupported birth types `BadNotSupported`). - **FOCAS / AbLegacy:** background loops are joined (≤ 5 s) on shutdown. +- **FOCAS (#257/#266):** `cnc_statinfo` flags are a `Status/` fixed-tree subtree (`FIXED:Status/Run`…, capability-probed); + adjacent PMC tags in one poll batch coalesce into one `pmc_rdpmcrng` (`PmcCoalesce` config, default on) — a refused range + falls back to per-tag reads, a transport failure fails all members without retry. Parts count stays `PARAM:6711` (docs only). - **Re-Initialize without teardown (#630):** FOCAS, AbLegacy, AbCip, TwinCAT, S7 and OpcUaClient `InitializeAsync` first release the prior device/session state (cancel, ≤ 5 s join, dispose — incl. the AbCip/FOCAS alarm projections) — the device-state half of `ShutdownAsync` only; the poll engine is kept and the actor re-subscribes diff --git a/docs/drivers/FOCAS.md b/docs/drivers/FOCAS.md index f2442d1b..95355e3e 100644 --- a/docs/drivers/FOCAS.md +++ b/docs/drivers/FOCAS.md @@ -146,7 +146,7 @@ reference them will throw at startup with a message pointing here. | Capability | Wire path | Notes | |------------|-----------|-------| | `IReadable` | `ReadAsync` → `cnc_rdpmcrng` / `cnc_rdparam` / `cnc_rdmacro` | One TCP request/response per read; `Focas.Wire` serializes requests on socket 2 internally | -| `IWritable` | returns `BadNotWritable` | OtOpcUa is read-only against FOCAS by design — no `cnc_wrparam` / `pmc_wrpmcrng` / `cnc_wrmacro` path is implemented | +| `IWritable` | returns `BadNotWritable` | OtOpcUa is read-only against FOCAS by design — no `cnc_wrparam` / `pmc_wrpmcrng` / `cnc_wrmacro` path is implemented, and none will be (policy: the stranded write/unlock backlog items #268–#271 are won't-do; a write path on a production CNC is a safety decision, not a feature gap) | | `ITagDiscovery` | `DiscoverAsync` | Emits `FOCAS/{device}/{tag}` folders per configured device | | `ISubscribable` | polled via shared `PollGroupEngine` | FOCAS has no push model — subscriptions turn into per-tag polling groups | | `IHostConnectivityProbe` | periodic `cnc_rdcncstat` | Probe cadence is `Probe.Interval`; transitions fire `OnHostStatusChanged` | @@ -224,15 +224,36 @@ What gets populated (all under `FOCAS/{deviceHostAddress}/`, addressed as | `Spindle/{name}/` | `Load` (Float64, % of rated load), `MotorSpeedPct` (Float64, % of maximum speed) — one folder per discovered spindle | `cnc_rdspdlname` once + `cnc_rdspmeter` type 0 + type 1 per tick | | `Program/` | `Name` (filename), `ONumber`, `Number`, `MainNumber`, `Sequence`, `BlockCount` — a field the CNC did not report (refused sub-read, payload without an O-number) reads `BadNoData`, never `0` | `cnc_exeprgname2` + `cnc_rdblkcount` + cached `cnc_rddynamic2` | | `OperationMode/` | `Mode` (int), `ModeText` ("AUTO", "MDI", "EDIT", …) | `cnc_rdopmode` | +| `Status/` | `Auto`, `Run`, `Motion`, `Mstb`, `Emergency`, `Alarm`, `Edit`, `TmMode` (raw FANUC codes, Int32) + `RunText` ("RESET", "STOP", "HOLD", "START", "MSTR") — see [CNC status flags](#cnc-status-flags-status) | `cnc_statinfo` (polled on `ProgramPollInterval`) | | `Timers/` | `PowerOnSeconds`, `OperatingSeconds`, `CuttingSeconds`, `CycleSeconds` | `cnc_rdtimer` × 4 | +### CNC status flags (`Status/`) + +`cnc_statinfo` (the `ODBST` block) is exposed as read-only `FIXED:Status/` nodes (#257). Values are the +control's raw codes, published verbatim as Int32 so a client sees exactly what the CNC reported: + +| Field | Codes | +|---|---| +| `Run` | 0 RESET, 1 STOP, 2 HOLD, 3 START, 4 MSTR (`RunText` is the label) | +| `Auto` | operation mode (0 MDI, 1 MEM, 3 EDIT, 4 HND, 5 JOG, … — control-dependent) | +| `Motion` | 0 none, 1 axis motion, 2 dwell | +| `Mstb` | 0 idle, 1 executing an M/S/T/B function | +| `Emergency` | 0 not, 1 emergency stop, 2 reset | +| `Alarm` | 0 none, 1 alarm, … (battery-low etc. are control-dependent) | +| `Edit`, `TmMode` | program-edit state; T/M mode | + +Author one the same way as any fixed-tree tag — `FIXED:Status/Run` (Int32), `FIXED:Status/RunText` (String). +Like `Program/`, the subtree is gated on a bootstrap probe: a control that refuses `cnc_statinfo` gets no +`Status/` nodes and the rest of the tree is unaffected. Reads are served from the poll snapshot, so they +issue no wire traffic and go `BadCommunicationError` once the device has stopped answering. + ### Per-series node suppression The driver probes each optional call once at bootstrap. If the target CNC returns `EW_FUNC` / `EW_NOOPT` / `EW_VERSION` on the wire, the corresponding subtree is **not emitted** — the operator doesn't see nodes that will only ever return `BadDeviceFailure`. Capability suppression -covers `Spindle/`, `Program/` + `OperationMode/`, `Timers/`, and +covers `Spindle/`, `Program/` + `OperationMode/`, `Status/`, `Timers/`, and per-axis `ServoLoad` independently. Identity + `Axes/*` position reads (which every Fanuc CNC supports) are always emitted. @@ -265,6 +286,54 @@ fallen back to `PositionDecimalPlaces` and published raw counts. numbers whose meaning is MTB-specific, and which no browse can discover. Those go under the device folder alongside the fixed subtree. +### Parts count (`PARAM:6711`) + +There is deliberately **no** dedicated parts-count node (#258): the count is an ordinary CNC parameter, so it +is already readable as a hand-authored `PARAM:` tag (Int32), and a fixed `Parts/` subtree would hard-code +parameter numbers that are control- and MTB-specific. On the standard FANUC 0i / 16i / 30i parameter map: + +| Address | Parameter | Meaning | +|---|---|---| +| `PARAM:6711` | No. 6711 | Number of parts machined (resets with the operator's clear) | +| `PARAM:6712` | No. 6712 | Total number of parts machined (cumulative) | +| `PARAM:6713` | No. 6713 | Number of parts required (the target) | + +The count-up trigger (which M code or cycle end increments it) is set by No. 6710 and bit 0 of No. 6700, +so confirm in the machine's parameter manual that the counter is actually enabled and that 6711–6713 mean +what the MTB says they mean before relying on them. Cycle time per part is not provided; the `Timers/` +subtree exposes `CycleSeconds` and a client can difference it across a `PARAM:6711` change. + +## PMC range coalescing + +A poll batch that reads several nearby PMC tags used to issue one `pmc_rdpmcrng` per tag. The driver now +merges tags of the same PMC letter and width that sit close together into **one** range read and slices each +tag's value out of the result (#266). Single, isolated PMC tags — the common "one tag per area" case — are +read exactly as before. + +```jsonc +"PmcCoalesce": { + "Enabled": true, // default + "MaxSpanBytes": 128, // hard cap on one merged range + "MaxGapBytes": 8 // bytes nobody asked for tolerated between two merged tags; 0 = only strictly adjacent +} +``` + +Rules worth knowing: + +- Tags merge only within the same letter and width class (Bit and Byte are one class; Word, Long, Float, Double + are separate). A multi-byte tag joins a range only when its offset from the range start is a multiple of its + width, because the CNC splits a range into width-sized slots from the start. +- **Spare gap bytes must be valid PMC addresses.** A gap that crosses a hole in the CNC's PMC map makes the CNC + refuse the whole range. That is handled, not fatal: a refused range is remembered per device and its tags are + read **individually**, each with its own true status (a refusal is never stamped onto healthy members). If + that happens routinely, lower `MaxGapBytes` (0 disables gap-spanning entirely) or set `Enabled` to `false`. +- A transport failure or timeout on the merged read fails every member with `BadCommunicationError` and does + **not** retry per tag — the link is down, so per-tag retries would only multiply timeouts. +- Read-only: this changes `pmc_rdpmcrng` reads only; nothing here can write. + +The config key lives in the driver `Config` JSON. The AdminUI typed form does not yet surface it (an unknown +key edited through that form is dropped), so set it in the raw config. + ## Alarm projection Alarm surfacing is **disabled by default** because the polling cost is wasted @@ -324,7 +393,9 @@ latency spike once per cadence. driver surface via `FakeFocasClient`. Includes the alarm-projection raise / clear diffing tests. - **Integration tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/` - hold the Docker simulator scaffold; see + hold the Docker simulator scaffold (`OTOPCUA_FOCAS_SIM_ENDPOINT=host:port`; they skip when it is absent; + `Series/WireBackendStatusAndPmcTests` covers `Status/` and PMC coalescing against the mock's `statinfo` and + `pmc` state); see [`docs/v2/implementation/focas-wire-protocol.md`](../v2/implementation/focas-wire-protocol.md) for what the simulator emits vs. real CNC behaviour. - **E2E script** — `scripts/e2e/test-focas.ps1` stages Host + Proxy + a real From c86204b7d6df953bb69506f02a00d1f453b6a82b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 8 Oct 2026 17:03:25 -0400 Subject: [PATCH 4/4] fix(focas): PMC coalescing treats any rc refusal as per-tag fallback, skips transient busy memo, one connect per batch (#266) Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe --- .../FocasDriver.cs | 44 ++++++++----- .../IFocasClient.cs | 5 +- .../Wire/WireFocasClient.cs | 9 ++- .../FocasPmcCoalescingDriverTests.cs | 62 +++++++++++++++++++ 4 files changed, 99 insertions(+), 21 deletions(-) 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 921164b4..6a656a36 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs @@ -634,6 +634,20 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, { if (items.Count < 2) continue; var plans = FocasPmcCoalescer.Plan(items, _options.PmcCoalesce.MaxSpanBytes, _options.PmcCoalesce.MaxGapBytes); + if (!plans.Any(p => p.Members.Count >= 2 && !device.CoalesceRejected.ContainsKey(p.Key))) continue; + + // One connect attempt per device per batch: if it fails, every PMC tag of the device fails with + // that status here instead of paying a connect attempt per plan and again per tag. + IFocasClient client; + try { client = await EnsureConnectedAsync(device, ct).ConfigureAwait(false); } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } + catch (Exception) + { + FailMembers(items, FocasStatusMapper.BadCommunicationError, references, results, now, ref served, + $"FOCAS connect failed reading PMC tags of {device.Options.HostAddress}"); + continue; + } + foreach (var plan in plans) { if (plan.Members.Count < 2) continue; @@ -642,7 +656,6 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, 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); @@ -659,44 +672,41 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, } 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; + // The CNC REFUSED the merged range (any return code, mapped or not). Don't guess which + // member is at fault: let the per-tag loop give each its own answer. Remember the range + // so later polls skip the doomed read — except for a transient refusal (EW_BUSY maps to + // BadDeviceFailure), which says nothing about the range. + if (status != FocasStatusMapper.BadDeviceFailure) + 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); + // Transport failure / timeout: the link is suspect, so fail the members without per-tag retries. + FailMembers(members, FocasStatusMapper.BadCommunicationError, references, results, now, ref served, + $"FOCAS transport failure reading PMC range {plan.Key}"); } } } return served; - void FailMembers(FocasPmcRangePlan plan, uint status, IReadOnlyList refs, - DataValueSnapshot[] res, DateTime stamp, ref bool[]? servedRef) + void FailMembers(IEnumerable failed, uint status, IReadOnlyList refs, + DataValueSnapshot[] res, DateTime stamp, ref bool[]? servedRef, string reason) { - foreach (var m in plan.Members) + foreach (var m in failed) { 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}")); + $"{reason} (0x{status:X8})")); } } 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 cdcecfea..ed69e3cf 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs @@ -84,7 +84,10 @@ public interface IFocasClient : IDisposable /// 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. + /// + /// The raw slot values (null when not Good) and the mapped OPC UA status. A returned non-Good status is + /// always a CNC return-code refusal; a transport failure or timeout throws instead. + /// Task<(IReadOnlyList? slots, uint status)> ReadPmcSlotsAsync( string letter, FocasDataType slotType, int start, int end, 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 311d3cbd..d4789061 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 @@ -71,7 +71,10 @@ 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); + // Contract: a RETURNED status is always a CNC return code (a refusal); a transport failure THROWS. + // The coalescing caller needs that distinction — it falls back per tag on a refusal but must not + // re-hit a dead link (#266). + RequireConnected(); cancellationToken.ThrowIfCancellationRequested(); var area = FocasPmcAreaLookup.FromLetter(letter); @@ -85,9 +88,9 @@ public sealed class WireFocasClient : IFocasClient return (null, FocasStatusMapper.MapFocasReturn(result.Rc)); return (result.Value.Values, FocasStatusMapper.Good); } - catch (FocasWireException ex) + catch (FocasWireException ex) when (ex.Rc is short rc) { - return (null, ex.Rc is short rc ? FocasStatusMapper.MapFocasReturn(rc) : FocasStatusMapper.BadCommunicationError); + return (null, FocasStatusMapper.MapFocasReturn(rc)); } } 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 index 6d26c83e..8c1219d7 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPmcCoalescingDriverTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPmcCoalescingDriverTests.cs @@ -132,6 +132,68 @@ public sealed class FocasPmcCoalescingDriverTests await drv.ShutdownAsync(CancellationToken.None); } + [Fact] + public async Task A_refusal_with_an_unmapped_rc_that_maps_to_BadCommunicationError_still_falls_back_per_tag() + { + var (drv, c) = await StartAsync(null, + Tag("A", "R100", FocasDataType.Byte), Tag("B", "R101", FocasDataType.Byte)); + c.PmcRangeRejectors.Add((_, _, _) => true); + c.PmcRangeRejectStatus = FocasStatusMapper.BadCommunicationError; // what an unlisted rc / EW_PARITY / EW_SOCKET maps to + c.Values["R100"] = (sbyte)1; c.Values["R101"] = (sbyte)2; + + var r = await drv.ReadAsync(["A", "B"], CancellationToken.None); + r.Select(s => s.Value).ShouldBe([(object)(sbyte)1, (sbyte)2]); + r.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good); + + await drv.ReadAsync(["A", "B"], CancellationToken.None); + c.PmcRangeReads.Count.ShouldBe(1, "a deterministic rc refusal is memoised"); + await drv.ShutdownAsync(CancellationToken.None); + } + + [Fact] + public async Task A_transient_busy_refusal_is_not_memoised() + { + var (drv, c) = await StartAsync(null, + Tag("A", "R100", FocasDataType.Byte), Tag("B", "R101", FocasDataType.Byte)); + var busy = true; + c.PmcRangeRejectors.Add((_, _, _) => busy); + c.PmcRangeRejectStatus = FocasStatusMapper.BadDeviceFailure; // EW_BUSY + c.Values["R100"] = (sbyte)1; c.Values["R101"] = (sbyte)2; + + (await drv.ReadAsync(["A", "B"], CancellationToken.None)).ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good); + busy = false; + c.PmcMemory[("R", 100)] = 5; c.PmcMemory[("R", 101)] = 6; + var r = await drv.ReadAsync(["A", "B"], CancellationToken.None); + + c.PmcRangeReads.Count.ShouldBe(2, "the range is retried once the CNC is no longer busy"); + r.Select(s => s.Value).ShouldBe([(object)(sbyte)5, (sbyte)6]); + await drv.ShutdownAsync(CancellationToken.None); + } + + [Fact] + public async Task A_down_device_costs_one_connect_attempt_per_batch_not_one_per_plan_and_tag() + { + var client = new FakeFocasClient { ThrowOnConnect = true }; + var drv = new FocasDriver(new FocasDriverOptions + { + Devices = [new FocasDeviceOptions(Host)], + Probe = new FocasProbeOptions { Enabled = false }, + RawTags = FocasRawTags.Entries(new[] + { + Tag("A", "R100", FocasDataType.Byte), Tag("B", "R101", FocasDataType.Byte), + Tag("C", "R900", FocasDataType.Byte), Tag("D", "R2000", FocasDataType.Byte), + }), + }, "drv-1", new FakeFocasClientFactory { Customise = () => client }); + await drv.InitializeAsync("{}", CancellationToken.None); + var before = client.ConnectCount; + + var r = await drv.ReadAsync(["A", "B", "C", "D"], CancellationToken.None); + + r.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.BadCommunicationError); + (client.ConnectCount - before).ShouldBe(1); + await drv.ShutdownAsync(CancellationToken.None); + } + [Fact] public async Task A_transport_failure_fails_every_member_without_per_tag_retries() {