Auto: focas-f2b — multi-path/multi-channel CNC
Adds optional `@N` path suffix to FocasAddress (PARAM:1815@2, R100@3.0, MACRO:500@2, DIAG:280@2/1) with PathId defaulting to 1 for back-compat. Per-device PathCount is discovered via cnc_rdpathnum at first connect and cached on DeviceState; reads with PathId>PathCount return BadOutOfRange. The driver issues cnc_setpath before each non-default-path read and tracks LastSetPath so repeat reads on the same path skip the wire call. Closes #264
This commit is contained in:
@@ -61,6 +61,25 @@ internal class FakeFocasClient : IFocasClient
|
||||
|
||||
public virtual Task<bool> ProbeAsync(CancellationToken ct) => Task.FromResult(ProbeResult);
|
||||
|
||||
/// <summary>
|
||||
/// Configurable path count surfaced via <see cref="GetPathCountAsync"/> — 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).
|
||||
/// </summary>
|
||||
public int PathCount { get; set; } = 1;
|
||||
|
||||
/// <summary>Ordered log of <c>cnc_setpath</c> calls observed on this fake session.</summary>
|
||||
public List<int> SetPathLog { get; } = new();
|
||||
|
||||
public virtual Task<int> 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++;
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Coverage for multi-path / multi-channel CNC support — parser, driver bootstrap,
|
||||
/// <c>cnc_setpath</c> dispatch (issue #264, plan PR F2-b). The <c>@N</c> suffix
|
||||
/// selects which path a given address is read from; default <c>PathId=1</c>
|
||||
/// preserves single-path back-compat.
|
||||
/// </summary>
|
||||
[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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user