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();
+ }
+}