diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasAddress.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasAddress.cs index 4c801e4..1c0727b 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasAddress.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasAddress.cs @@ -16,27 +16,42 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS; /// bit index when present is 0–7 and uses .N for PMC or /N for parameters. /// Diagnostic addresses reuse the /N form to encode an axis index — BitIndex /// carries the 1-based axis number (0 = whole-CNC diagnostic). +/// +/// Multi-path / multi-channel CNCs (e.g. lathe + sub-spindle, dual-turret) expose multiple +/// "paths"; selects which one a given address is read from. Encoded +/// as a trailing @N after the address body but before any bit / axis suffix — +/// R100@2, PARAM:1815@2, PARAM:1815@2/0, MACRO:500@3, +/// DIAG:280@2/1. Defaults to 1 for back-compat (single-path CNCs). +/// /// public sealed record FocasAddress( FocasAreaKind Kind, string? PmcLetter, int Number, - int? BitIndex) + int? BitIndex, + int PathId = 1) { - public string Canonical => Kind switch + public string Canonical { - FocasAreaKind.Pmc => BitIndex is null - ? $"{PmcLetter}{Number}" - : $"{PmcLetter}{Number}.{BitIndex}", - FocasAreaKind.Parameter => BitIndex is null - ? $"PARAM:{Number}" - : $"PARAM:{Number}/{BitIndex}", - FocasAreaKind.Macro => $"MACRO:{Number}", - FocasAreaKind.Diagnostic => BitIndex is null or 0 - ? $"DIAG:{Number}" - : $"DIAG:{Number}/{BitIndex}", - _ => $"?{Number}", - }; + get + { + var pathSuffix = PathId == 1 ? string.Empty : $"@{PathId}"; + return Kind switch + { + FocasAreaKind.Pmc => BitIndex is null + ? $"{PmcLetter}{Number}{pathSuffix}" + : $"{PmcLetter}{Number}{pathSuffix}.{BitIndex}", + FocasAreaKind.Parameter => BitIndex is null + ? $"PARAM:{Number}{pathSuffix}" + : $"PARAM:{Number}{pathSuffix}/{BitIndex}", + FocasAreaKind.Macro => $"MACRO:{Number}{pathSuffix}", + FocasAreaKind.Diagnostic => BitIndex is null or 0 + ? $"DIAG:{Number}{pathSuffix}" + : $"DIAG:{Number}{pathSuffix}/{BitIndex}", + _ => $"?{Number}", + }; + } + } public static FocasAddress? TryParse(string? value) { @@ -52,7 +67,7 @@ public sealed record FocasAddress( if (src.StartsWith("DIAG:", StringComparison.OrdinalIgnoreCase)) return ParseScoped(src["DIAG:".Length..], FocasAreaKind.Diagnostic, bitSeparator: '/'); - // PMC path: letter + digits + optional .bit + // PMC path: letter + digits + optional @path + optional .bit if (src.Length < 2 || !char.IsLetter(src[0])) return null; var letter = src[0..1].ToUpperInvariant(); if (!IsValidPmcLetter(letter)) return null; @@ -67,8 +82,15 @@ public sealed record FocasAddress( bit = bitValue; remainder = remainder[..dotIdx]; } + var pmcPath = 1; + var atIdx = remainder.IndexOf('@'); + if (atIdx >= 0) + { + if (!TryParsePathId(remainder[(atIdx + 1)..], out pmcPath)) return null; + remainder = remainder[..atIdx]; + } if (!int.TryParse(remainder, out var number) || number < 0) return null; - return new FocasAddress(FocasAreaKind.Pmc, letter, number, bit); + return new FocasAddress(FocasAreaKind.Pmc, letter, number, bit, pmcPath); } private static FocasAddress? ParseScoped(string body, FocasAreaKind kind, char? bitSeparator) @@ -85,8 +107,30 @@ public sealed record FocasAddress( body = body[..slashIdx]; } } + // Path suffix (@N) sits between the body number and any bit/axis (which has already + // been peeled off above): PARAM:1815@2/0 → body="1815@2", bit=0. + var path = 1; + var atIdx = body.IndexOf('@'); + if (atIdx >= 0) + { + if (!TryParsePathId(body[(atIdx + 1)..], out path)) return null; + body = body[..atIdx]; + } if (!int.TryParse(body, out var number) || number < 0) return null; - return new FocasAddress(kind, PmcLetter: null, number, bit); + return new FocasAddress(kind, PmcLetter: null, number, bit, path); + } + + private static bool TryParsePathId(string text, out int pathId) + { + // Path 0 is reserved (FOCAS path numbering is 1-based); upper-bound is the FWLIB + // ceiling — Fanuc spec lists 10 paths max even on the largest 30i-B configurations. + if (int.TryParse(text, out var v) && v is >= 1 and <= 10) + { + pathId = v; + return true; + } + pathId = 0; + return false; } private static bool IsValidPmcLetter(string letter) => letter switch diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs index 51b2264..7c0e3d3 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs @@ -377,6 +377,20 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false); var parsed = FocasAddress.TryParse(def.Address) ?? throw new InvalidOperationException($"FOCAS tag '{def.Name}' has malformed Address '{def.Address}'."); + // Multi-path validation + dispatch (issue #264). PathId=1 is the back-compat + // default and skips the cnc_setpath call entirely; non-default paths are + // bounded against the device's cached PathCount and only switch the active + // path when it differs from the last one set on the session. + if (parsed.PathId > 1 && device.PathCount > 0 && parsed.PathId > device.PathCount) + { + results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadOutOfRange, null, now); + continue; + } + if (parsed.PathId != 1 && device.LastSetPath != parsed.PathId) + { + await client.SetPathAsync(parsed.PathId, cancellationToken).ConfigureAwait(false); + device.LastSetPath = parsed.PathId; + } var (value, status) = parsed.Kind == FocasAreaKind.Diagnostic ? await client.ReadDiagnosticAsync( parsed.Number, parsed.BitIndex ?? 0, def.DataType, cancellationToken).ConfigureAwait(false) @@ -432,6 +446,16 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false); var parsed = FocasAddress.TryParse(def.Address) ?? throw new InvalidOperationException($"FOCAS tag '{def.Name}' has malformed Address '{def.Address}'."); + if (parsed.PathId > 1 && device.PathCount > 0 && parsed.PathId > device.PathCount) + { + results[i] = new WriteResult(FocasStatusMapper.BadOutOfRange); + continue; + } + if (parsed.PathId != 1 && device.LastSetPath != parsed.PathId) + { + await client.SetPathAsync(parsed.PathId, cancellationToken).ConfigureAwait(false); + device.LastSetPath = parsed.PathId; + } var status = await client.WriteAsync(parsed, def.DataType, w.Value, cancellationToken).ConfigureAwait(false); results[i] = new WriteResult(status); } @@ -1089,6 +1113,19 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, device.Client = null; throw; } + // Multi-path bootstrap (issue #264). cnc_rdpathnum runs once per session — the + // controller's path topology is fixed at boot. A reconnect resets the wire + // session's "last set path" so the next non-default-path read forces a fresh + // cnc_setpath; that's why LastSetPath is reset alongside PathCount here. + try + { + device.PathCount = await device.Client.GetPathCountAsync(ct).ConfigureAwait(false); + } + catch + { + device.PathCount = 1; + } + device.LastSetPath = 0; return device.Client; } @@ -1190,6 +1227,24 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, public string? LastErrorMessage; public DateTime LastSuccessfulReadUtc; + /// + /// CNC path topology cached at first successful connect via + /// cnc_rdpathnum (issue #264). 1 for single-path controllers; 2..N for + /// multi-path lathes / dual-turret machines. The driver validates per-tag + /// FocasAddress.PathId against this count + rejects with + /// BadOutOfRange when the tag points beyond what the CNC reports. + /// + public int PathCount { get; set; } = 1; + + /// + /// Most recent path number set on the wire session via cnc_setpath, + /// or 0 when no path has been set yet (fresh session). The driver + /// skips redundant cnc_setpath calls when the tag's PathId + /// matches the last-set value; reconnects reset this to 0 so the + /// next non-default-path read forces a fresh switch (issue #264). + /// + public int LastSetPath { get; set; } + public void DisposeClient() { Client?.Dispose(); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs index 8a4d2b6..9ff8ae2 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs @@ -139,6 +139,26 @@ internal sealed class FwlibFocasClient : IFocasClient } } + public Task GetPathCountAsync(CancellationToken cancellationToken) + { + if (!_connected) return Task.FromResult(1); + var buf = new FwlibNative.ODBPATH(); + var ret = FwlibNative.RdPathNum(_handle, ref buf); + // EW_FUNC / EW_NOOPT on single-path controllers — fall back to 1 rather than failing. + if (ret != 0 || buf.MaxPath < 1) return Task.FromResult(1); + return Task.FromResult((int)buf.MaxPath); + } + + public Task SetPathAsync(int pathId, CancellationToken cancellationToken) + { + if (!_connected) return Task.CompletedTask; + var ret = FwlibNative.SetPath(_handle, (short)pathId); + if (ret != 0) + throw new InvalidOperationException( + $"FWLIB cnc_setpath failed with EW_{ret} switching to path {pathId}."); + return Task.CompletedTask; + } + public Task ProbeAsync(CancellationToken cancellationToken) { if (!_connected) return Task.FromResult(false); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs index ad6ba3b..8ff9713 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs @@ -195,6 +195,27 @@ internal static class FwlibNative short length, ref IODBPSD buffer); + // ---- Multi-path / multi-channel ---- + + /// + /// cnc_rdpathnum — read the number of CNC paths (channels) the controller + /// exposes + the currently-active path. Multi-path CNCs (lathe + sub-spindle, + /// dual-turret) return 2..N; single-path CNCs return 1. The driver caches + /// at connect and uses it to validate per-tag + /// PathId values (issue #264). + /// + [DllImport(Library, EntryPoint = "cnc_rdpathnum", ExactSpelling = true)] + public static extern short RdPathNum(ushort handle, ref ODBPATH buffer); + + /// + /// cnc_setpath — switch the active CNC path (channel) for subsequent + /// calls. is 1-based. The driver issues this before + /// every read whose path differs from the last one set on the session; + /// single-path tags (PathId=1 only) skip the call entirely (issue #264). + /// + [DllImport(Library, EntryPoint = "cnc_setpath", ExactSpelling = true)] + public static extern short SetPath(ushort handle, short path); + // ---- Currently-executing block ---- /// @@ -361,6 +382,19 @@ internal static class FwlibNative public byte[] Data; } + /// + /// ODBPATH — cnc_rdpathnum reply. is the currently-active + /// path (1-based); is the controller's path count. We consume + /// at bootstrap to validate per-tag PathId; runtime path + /// selection happens via (issue #264). + /// + [StructLayout(LayoutKind.Sequential, Pack = 1)] + public struct ODBPATH + { + public short PathNo; + public short MaxPath; + } + /// ODBST — CNC status info. Machine state, alarm flags, automatic / edit mode. [StructLayout(LayoutKind.Sequential, Pack = 1)] public struct ODBST diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs index bd91c9d..14e09b4 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs @@ -162,6 +162,28 @@ public interface IFocasClient : IDisposable Task<(object? value, uint status)> ReadDiagnosticAsync( int diagNumber, int axisOrZero, FocasDataType type, CancellationToken cancellationToken) => Task.FromResult<(object?, uint)>((null, FocasStatusMapper.BadNotSupported)); + + /// + /// Discover the number of CNC paths (channels) the controller exposes via + /// cnc_rdpathnum. Multi-path CNCs (lathe + sub-spindle, dual-turret, + /// etc.) report 2..N; single-path CNCs return 1. The driver caches the result + /// once per device after connect + uses it to validate per-tag PathId + /// values (issue #264). Default returns 1 so transports that haven't extended + /// their wire surface keep behaving as single-path. + /// + Task GetPathCountAsync(CancellationToken cancellationToken) + => Task.FromResult(1); + + /// + /// Switch the active CNC path (channel) for subsequent reads via + /// cnc_setpath. Called by the driver before every read whose + /// FocasAddress.PathId differs from the path most recently set on the + /// session — single-path devices (PathId=1 only) skip the wire call entirely. + /// Default is a no-op so transports that haven't extended their wire surface + /// simply read whatever path the CNC has selected (issue #264). + /// + Task SetPathAsync(int pathId, CancellationToken cancellationToken) + => Task.CompletedTask; } /// diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs index 4a10759..c332bd4 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs @@ -61,6 +61,25 @@ internal class FakeFocasClient : IFocasClient public virtual Task ProbeAsync(CancellationToken ct) => Task.FromResult(ProbeResult); + /// + /// Configurable path count surfaced via — defaults to + /// 1 (single-path controller). Tests asserting multi-path behaviour set this to 2..N + /// so the driver's PathId validation + cnc_setpath dispatch can be exercised + /// without a live CNC (issue #264). + /// + public int PathCount { get; set; } = 1; + + /// Ordered log of cnc_setpath calls observed on this fake session. + public List SetPathLog { get; } = new(); + + public virtual Task GetPathCountAsync(CancellationToken ct) => Task.FromResult(PathCount); + + public virtual Task SetPathAsync(int pathId, CancellationToken ct) + { + SetPathLog.Add(pathId); + return Task.CompletedTask; + } + public virtual void Dispose() { DisposeCount++; diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasMultiPathTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasMultiPathTests.cs new file mode 100644 index 0000000..2e1f58a --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasMultiPathTests.cs @@ -0,0 +1,267 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; + +/// +/// Coverage for multi-path / multi-channel CNC support — parser, driver bootstrap, +/// cnc_setpath dispatch (issue #264, plan PR F2-b). The @N suffix +/// selects which path a given address is read from; default PathId=1 +/// preserves single-path back-compat. +/// +[Trait("Category", "Unit")] +public sealed class FocasMultiPathTests +{ + // ---- Parser positive ---- + + [Theory] + [InlineData("R100", "R", 100, null, 1)] + [InlineData("R100@2", "R", 100, null, 2)] + [InlineData("R100@3.0", "R", 100, 0, 3)] + [InlineData("X0.7", "X", 0, 7, 1)] + [InlineData("X0@2.7", "X", 0, 7, 2)] + public void TryParse_PMC_supports_optional_path_suffix( + string input, string letter, int number, int? bit, int expectedPath) + { + var parsed = FocasAddress.TryParse(input); + parsed.ShouldNotBeNull(); + parsed.Kind.ShouldBe(FocasAreaKind.Pmc); + parsed.PmcLetter.ShouldBe(letter); + parsed.Number.ShouldBe(number); + parsed.BitIndex.ShouldBe(bit); + parsed.PathId.ShouldBe(expectedPath); + } + + [Theory] + [InlineData("PARAM:1815", 1815, null, 1)] + [InlineData("PARAM:1815@2", 1815, null, 2)] + [InlineData("PARAM:1815@2/0", 1815, 0, 2)] + [InlineData("PARAM:1815/0", 1815, 0, 1)] + public void TryParse_PARAM_supports_optional_path_suffix( + string input, int number, int? bit, int expectedPath) + { + var parsed = FocasAddress.TryParse(input); + parsed.ShouldNotBeNull(); + parsed.Kind.ShouldBe(FocasAreaKind.Parameter); + parsed.Number.ShouldBe(number); + parsed.BitIndex.ShouldBe(bit); + parsed.PathId.ShouldBe(expectedPath); + } + + [Theory] + [InlineData("MACRO:500", 500, 1)] + [InlineData("MACRO:500@2", 500, 2)] + [InlineData("MACRO:500@10", 500, 10)] + public void TryParse_MACRO_supports_optional_path_suffix(string input, int number, int expectedPath) + { + var parsed = FocasAddress.TryParse(input); + parsed.ShouldNotBeNull(); + parsed.Kind.ShouldBe(FocasAreaKind.Macro); + parsed.Number.ShouldBe(number); + parsed.PathId.ShouldBe(expectedPath); + } + + [Theory] + [InlineData("DIAG:280", 280, 0, 1)] + [InlineData("DIAG:280@2", 280, 0, 2)] + [InlineData("DIAG:280@2/1", 280, 1, 2)] + [InlineData("DIAG:280/1", 280, 1, 1)] + public void TryParse_DIAG_supports_optional_path_suffix( + string input, int number, int axis, int expectedPath) + { + var parsed = FocasAddress.TryParse(input); + parsed.ShouldNotBeNull(); + parsed.Kind.ShouldBe(FocasAreaKind.Diagnostic); + parsed.Number.ShouldBe(number); + (parsed.BitIndex ?? 0).ShouldBe(axis); + parsed.PathId.ShouldBe(expectedPath); + } + + // ---- Parser negative ---- + + [Theory] + [InlineData("R100@0")] // path 0 — reserved (FOCAS path numbering is 1-based) + [InlineData("R100@-1")] // negative path + [InlineData("R100@11")] // above FWLIB ceiling + [InlineData("R100@abc")] // non-numeric + [InlineData("R100@")] // empty + [InlineData("PARAM:1815@0")] + [InlineData("PARAM:1815@99")] + [InlineData("MACRO:500@0")] + [InlineData("DIAG:280@0/1")] + public void TryParse_rejects_invalid_path_suffix(string input) + { + FocasAddress.TryParse(input).ShouldBeNull(); + } + + // ---- Canonical round-trip ---- + + [Theory] + [InlineData("R100")] + [InlineData("R100@2")] + [InlineData("R100@3.0")] + [InlineData("PARAM:1815")] + [InlineData("PARAM:1815@2")] + [InlineData("PARAM:1815@2/0")] + [InlineData("MACRO:500")] + [InlineData("MACRO:500@2")] + [InlineData("DIAG:280")] + [InlineData("DIAG:280@2")] + [InlineData("DIAG:280@2/1")] + public void Canonical_round_trips_through_parser(string input) + { + var parsed = FocasAddress.TryParse(input); + parsed.ShouldNotBeNull(); + parsed.Canonical.ShouldBe(input); + } + + // ---- Driver dispatch ---- + + private static (FocasDriver drv, FakeFocasClientFactory factory) NewDriver( + params FocasTagDefinition[] tags) + { + var factory = new FakeFocasClientFactory(); + var drv = new FocasDriver(new FocasDriverOptions + { + Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")], + Tags = tags, + Probe = new FocasProbeOptions { Enabled = false }, + }, "drv-1", factory); + return (drv, factory); + } + + [Fact] + public async Task Default_PathId_1_does_not_trigger_SetPath() + { + var (drv, factory) = NewDriver( + new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = () => new FakeFocasClient + { + PathCount = 2, + Values = { ["R100"] = (sbyte)1 }, + }; + + var snapshots = await drv.ReadAsync(["X"], CancellationToken.None); + snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.Good); + factory.Clients.Single().SetPathLog.ShouldBeEmpty(); + } + + [Fact] + public async Task Non_default_PathId_calls_SetPath_before_read() + { + var (drv, factory) = NewDriver( + new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100@2", FocasDataType.Byte)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = () => new FakeFocasClient + { + PathCount = 2, + Values = { ["R100@2"] = (sbyte)7 }, + }; + + var snapshots = await drv.ReadAsync(["X"], CancellationToken.None); + snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.Good); + snapshots.Single().Value.ShouldBe((sbyte)7); + factory.Clients.Single().SetPathLog.ShouldBe(new[] { 2 }); + } + + [Fact] + public async Task Repeat_read_on_same_path_only_calls_SetPath_once() + { + var (drv, factory) = NewDriver( + new FocasTagDefinition("A", "focas://10.0.0.5:8193", "R100@2", FocasDataType.Byte), + new FocasTagDefinition("B", "focas://10.0.0.5:8193", "R101@2", FocasDataType.Byte)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = () => new FakeFocasClient + { + PathCount = 2, + Values = { ["R100@2"] = (sbyte)1, ["R101@2"] = (sbyte)2 }, + }; + + await drv.ReadAsync(["A", "B"], CancellationToken.None); + // Two reads on the same non-default path — SetPath should only fire on the first. + factory.Clients.Single().SetPathLog.ShouldBe(new[] { 2 }); + } + + [Fact] + public async Task Switching_paths_in_one_read_batch_logs_each_change() + { + var (drv, factory) = NewDriver( + new FocasTagDefinition("A", "focas://10.0.0.5:8193", "R100@2", FocasDataType.Byte), + new FocasTagDefinition("B", "focas://10.0.0.5:8193", "R200@3", FocasDataType.Byte), + new FocasTagDefinition("C", "focas://10.0.0.5:8193", "R300@2", FocasDataType.Byte)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = () => new FakeFocasClient + { + PathCount = 3, + Values = + { + ["R100@2"] = (sbyte)1, + ["R200@3"] = (sbyte)2, + ["R300@2"] = (sbyte)3, + }, + }; + + await drv.ReadAsync(["A", "B", "C"], CancellationToken.None); + // Path 2 → 3 → 2 — each transition fires SetPath. + factory.Clients.Single().SetPathLog.ShouldBe(new[] { 2, 3, 2 }); + } + + [Fact] + public async Task PathId_above_PathCount_returns_BadOutOfRange() + { + var (drv, factory) = NewDriver( + new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100@5", FocasDataType.Byte)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = () => new FakeFocasClient { PathCount = 2 }; + + var snapshots = await drv.ReadAsync(["X"], CancellationToken.None); + snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.BadOutOfRange); + // Out-of-range tag must not pollute the wire with a setpath call. + factory.Clients.Single().SetPathLog.ShouldBeEmpty(); + } + + [Fact] + public async Task Diagnostic_path_threads_through_SetPath_then_ReadDiagnosticAsync() + { + var (drv, factory) = NewDriver( + new FocasTagDefinition("ServoLoad", "focas://10.0.0.5:8193", "DIAG:280@2/1", FocasDataType.Int16)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = () => new FakeFocasClient + { + PathCount = 2, + Values = { ["DIAG:280/1"] = (short)42 }, + }; + + var snapshots = await drv.ReadAsync(["ServoLoad"], CancellationToken.None); + snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.Good); + snapshots.Single().Value.ShouldBe((short)42); + var fake = factory.Clients.Single(); + fake.SetPathLog.ShouldBe(new[] { 2 }); + fake.DiagnosticReads.Single().ShouldBe((280, 1, FocasDataType.Int16)); + } + + [Fact] + public async Task Single_path_controller_with_default_addresses_never_calls_SetPath() + { + var (drv, factory) = NewDriver( + new FocasTagDefinition("A", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte), + new FocasTagDefinition("B", "focas://10.0.0.5:8193", "PARAM:1815", FocasDataType.Int32), + new FocasTagDefinition("C", "focas://10.0.0.5:8193", "MACRO:500", FocasDataType.Float64)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = () => new FakeFocasClient + { + PathCount = 1, + Values = + { + ["R100"] = (sbyte)1, + ["PARAM:1815"] = 100, + ["MACRO:500"] = 1.5, + }, + }; + + await drv.ReadAsync(["A", "B", "C"], CancellationToken.None); + factory.Clients.Single().SetPathLog.ShouldBeEmpty(); + } +}