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