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:
@@ -16,27 +16,42 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
|||||||
/// bit index when present is 0–7 and uses <c>.N</c> for PMC or <c>/N</c> for parameters.
|
/// bit index when present is 0–7 and uses <c>.N</c> for PMC or <c>/N</c> for parameters.
|
||||||
/// Diagnostic addresses reuse the <c>/N</c> form to encode an axis index — <c>BitIndex</c>
|
/// Diagnostic addresses reuse the <c>/N</c> form to encode an axis index — <c>BitIndex</c>
|
||||||
/// carries the 1-based axis number (0 = whole-CNC diagnostic).
|
/// carries the 1-based axis number (0 = whole-CNC diagnostic).
|
||||||
|
/// <para>
|
||||||
|
/// Multi-path / multi-channel CNCs (e.g. lathe + sub-spindle, dual-turret) expose multiple
|
||||||
|
/// "paths"; <see cref="PathId"/> selects which one a given address is read from. Encoded
|
||||||
|
/// as a trailing <c>@N</c> after the address body but before any bit / axis suffix —
|
||||||
|
/// <c>R100@2</c>, <c>PARAM:1815@2</c>, <c>PARAM:1815@2/0</c>, <c>MACRO:500@3</c>,
|
||||||
|
/// <c>DIAG:280@2/1</c>. Defaults to <c>1</c> for back-compat (single-path CNCs).
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed record FocasAddress(
|
public sealed record FocasAddress(
|
||||||
FocasAreaKind Kind,
|
FocasAreaKind Kind,
|
||||||
string? PmcLetter,
|
string? PmcLetter,
|
||||||
int Number,
|
int Number,
|
||||||
int? BitIndex)
|
int? BitIndex,
|
||||||
|
int PathId = 1)
|
||||||
{
|
{
|
||||||
public string Canonical => Kind switch
|
public string Canonical
|
||||||
{
|
{
|
||||||
FocasAreaKind.Pmc => BitIndex is null
|
get
|
||||||
? $"{PmcLetter}{Number}"
|
{
|
||||||
: $"{PmcLetter}{Number}.{BitIndex}",
|
var pathSuffix = PathId == 1 ? string.Empty : $"@{PathId}";
|
||||||
FocasAreaKind.Parameter => BitIndex is null
|
return Kind switch
|
||||||
? $"PARAM:{Number}"
|
{
|
||||||
: $"PARAM:{Number}/{BitIndex}",
|
FocasAreaKind.Pmc => BitIndex is null
|
||||||
FocasAreaKind.Macro => $"MACRO:{Number}",
|
? $"{PmcLetter}{Number}{pathSuffix}"
|
||||||
FocasAreaKind.Diagnostic => BitIndex is null or 0
|
: $"{PmcLetter}{Number}{pathSuffix}.{BitIndex}",
|
||||||
? $"DIAG:{Number}"
|
FocasAreaKind.Parameter => BitIndex is null
|
||||||
: $"DIAG:{Number}/{BitIndex}",
|
? $"PARAM:{Number}{pathSuffix}"
|
||||||
_ => $"?{Number}",
|
: $"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)
|
public static FocasAddress? TryParse(string? value)
|
||||||
{
|
{
|
||||||
@@ -52,7 +67,7 @@ public sealed record FocasAddress(
|
|||||||
if (src.StartsWith("DIAG:", StringComparison.OrdinalIgnoreCase))
|
if (src.StartsWith("DIAG:", StringComparison.OrdinalIgnoreCase))
|
||||||
return ParseScoped(src["DIAG:".Length..], FocasAreaKind.Diagnostic, bitSeparator: '/');
|
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;
|
if (src.Length < 2 || !char.IsLetter(src[0])) return null;
|
||||||
var letter = src[0..1].ToUpperInvariant();
|
var letter = src[0..1].ToUpperInvariant();
|
||||||
if (!IsValidPmcLetter(letter)) return null;
|
if (!IsValidPmcLetter(letter)) return null;
|
||||||
@@ -67,8 +82,15 @@ public sealed record FocasAddress(
|
|||||||
bit = bitValue;
|
bit = bitValue;
|
||||||
remainder = remainder[..dotIdx];
|
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;
|
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)
|
private static FocasAddress? ParseScoped(string body, FocasAreaKind kind, char? bitSeparator)
|
||||||
@@ -85,8 +107,30 @@ public sealed record FocasAddress(
|
|||||||
body = body[..slashIdx];
|
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;
|
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
|
private static bool IsValidPmcLetter(string letter) => letter switch
|
||||||
|
|||||||
@@ -377,6 +377,20 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
||||||
var parsed = FocasAddress.TryParse(def.Address)
|
var parsed = FocasAddress.TryParse(def.Address)
|
||||||
?? throw new InvalidOperationException($"FOCAS tag '{def.Name}' has malformed Address '{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
|
var (value, status) = parsed.Kind == FocasAreaKind.Diagnostic
|
||||||
? await client.ReadDiagnosticAsync(
|
? await client.ReadDiagnosticAsync(
|
||||||
parsed.Number, parsed.BitIndex ?? 0, def.DataType, cancellationToken).ConfigureAwait(false)
|
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 client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
||||||
var parsed = FocasAddress.TryParse(def.Address)
|
var parsed = FocasAddress.TryParse(def.Address)
|
||||||
?? throw new InvalidOperationException($"FOCAS tag '{def.Name}' has malformed Address '{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);
|
var status = await client.WriteAsync(parsed, def.DataType, w.Value, cancellationToken).ConfigureAwait(false);
|
||||||
results[i] = new WriteResult(status);
|
results[i] = new WriteResult(status);
|
||||||
}
|
}
|
||||||
@@ -1089,6 +1113,19 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
device.Client = null;
|
device.Client = null;
|
||||||
throw;
|
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;
|
return device.Client;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1190,6 +1227,24 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
public string? LastErrorMessage;
|
public string? LastErrorMessage;
|
||||||
public DateTime LastSuccessfulReadUtc;
|
public DateTime LastSuccessfulReadUtc;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// CNC path topology cached at first successful connect via
|
||||||
|
/// <c>cnc_rdpathnum</c> (issue #264). 1 for single-path controllers; 2..N for
|
||||||
|
/// multi-path lathes / dual-turret machines. The driver validates per-tag
|
||||||
|
/// <c>FocasAddress.PathId</c> against this count + rejects with
|
||||||
|
/// <c>BadOutOfRange</c> when the tag points beyond what the CNC reports.
|
||||||
|
/// </summary>
|
||||||
|
public int PathCount { get; set; } = 1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Most recent path number set on the wire session via <c>cnc_setpath</c>,
|
||||||
|
/// or <c>0</c> when no path has been set yet (fresh session). The driver
|
||||||
|
/// skips redundant <c>cnc_setpath</c> calls when the tag's <c>PathId</c>
|
||||||
|
/// matches the last-set value; reconnects reset this to <c>0</c> so the
|
||||||
|
/// next non-default-path read forces a fresh switch (issue #264).
|
||||||
|
/// </summary>
|
||||||
|
public int LastSetPath { get; set; }
|
||||||
|
|
||||||
public void DisposeClient()
|
public void DisposeClient()
|
||||||
{
|
{
|
||||||
Client?.Dispose();
|
Client?.Dispose();
|
||||||
|
|||||||
@@ -139,6 +139,26 @@ internal sealed class FwlibFocasClient : IFocasClient
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<int> 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<bool> ProbeAsync(CancellationToken cancellationToken)
|
public Task<bool> ProbeAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (!_connected) return Task.FromResult(false);
|
if (!_connected) return Task.FromResult(false);
|
||||||
|
|||||||
@@ -195,6 +195,27 @@ internal static class FwlibNative
|
|||||||
short length,
|
short length,
|
||||||
ref IODBPSD buffer);
|
ref IODBPSD buffer);
|
||||||
|
|
||||||
|
// ---- Multi-path / multi-channel ----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>cnc_rdpathnum</c> — 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
|
||||||
|
/// <see cref="ODBPATH.MaxPath"/> at connect and uses it to validate per-tag
|
||||||
|
/// <c>PathId</c> values (issue #264).
|
||||||
|
/// </summary>
|
||||||
|
[DllImport(Library, EntryPoint = "cnc_rdpathnum", ExactSpelling = true)]
|
||||||
|
public static extern short RdPathNum(ushort handle, ref ODBPATH buffer);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>cnc_setpath</c> — switch the active CNC path (channel) for subsequent
|
||||||
|
/// calls. <paramref name="path"/> 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).
|
||||||
|
/// </summary>
|
||||||
|
[DllImport(Library, EntryPoint = "cnc_setpath", ExactSpelling = true)]
|
||||||
|
public static extern short SetPath(ushort handle, short path);
|
||||||
|
|
||||||
// ---- Currently-executing block ----
|
// ---- Currently-executing block ----
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -361,6 +382,19 @@ internal static class FwlibNative
|
|||||||
public byte[] Data;
|
public byte[] Data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ODBPATH — <c>cnc_rdpathnum</c> reply. <see cref="PathNo"/> is the currently-active
|
||||||
|
/// path (1-based); <see cref="MaxPath"/> is the controller's path count. We consume
|
||||||
|
/// <see cref="MaxPath"/> at bootstrap to validate per-tag PathId; runtime path
|
||||||
|
/// selection happens via <see cref="SetPath"/> (issue #264).
|
||||||
|
/// </summary>
|
||||||
|
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||||
|
public struct ODBPATH
|
||||||
|
{
|
||||||
|
public short PathNo;
|
||||||
|
public short MaxPath;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>ODBST — CNC status info. Machine state, alarm flags, automatic / edit mode.</summary>
|
/// <summary>ODBST — CNC status info. Machine state, alarm flags, automatic / edit mode.</summary>
|
||||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||||
public struct ODBST
|
public struct ODBST
|
||||||
|
|||||||
@@ -162,6 +162,28 @@ public interface IFocasClient : IDisposable
|
|||||||
Task<(object? value, uint status)> ReadDiagnosticAsync(
|
Task<(object? value, uint status)> ReadDiagnosticAsync(
|
||||||
int diagNumber, int axisOrZero, FocasDataType type, CancellationToken cancellationToken)
|
int diagNumber, int axisOrZero, FocasDataType type, CancellationToken cancellationToken)
|
||||||
=> Task.FromResult<(object?, uint)>((null, FocasStatusMapper.BadNotSupported));
|
=> Task.FromResult<(object?, uint)>((null, FocasStatusMapper.BadNotSupported));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Discover the number of CNC paths (channels) the controller exposes via
|
||||||
|
/// <c>cnc_rdpathnum</c>. 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 <c>PathId</c>
|
||||||
|
/// values (issue #264). Default returns 1 so transports that haven't extended
|
||||||
|
/// their wire surface keep behaving as single-path.
|
||||||
|
/// </summary>
|
||||||
|
Task<int> GetPathCountAsync(CancellationToken cancellationToken)
|
||||||
|
=> Task.FromResult(1);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Switch the active CNC path (channel) for subsequent reads via
|
||||||
|
/// <c>cnc_setpath</c>. Called by the driver before every read whose
|
||||||
|
/// <c>FocasAddress.PathId</c> 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).
|
||||||
|
/// </summary>
|
||||||
|
Task SetPathAsync(int pathId, CancellationToken cancellationToken)
|
||||||
|
=> Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -61,6 +61,25 @@ internal class FakeFocasClient : IFocasClient
|
|||||||
|
|
||||||
public virtual Task<bool> ProbeAsync(CancellationToken ct) => Task.FromResult(ProbeResult);
|
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()
|
public virtual void Dispose()
|
||||||
{
|
{
|
||||||
DisposeCount++;
|
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