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:
Joseph Doherty
2026-04-25 19:42:58 -04:00
parent 3b82f4f5fb
commit 2f3eeecd17
7 changed files with 478 additions and 17 deletions

View File

@@ -16,27 +16,42 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// bit index when present is 07 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>
/// 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>
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

View File

@@ -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;
/// <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()
{
Client?.Dispose();

View File

@@ -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)
{
if (!_connected) return Task.FromResult(false);

View File

@@ -195,6 +195,27 @@ internal static class FwlibNative
short length,
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 ----
/// <summary>
@@ -361,6 +382,19 @@ internal static class FwlibNative
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>
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct ODBST

View File

@@ -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));
/// <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>