Auto: focas-f1d — Tool number + work coordinate offsets

Closes #260
This commit is contained in:
Joseph Doherty
2026-04-25 14:37:51 -04:00
parent 49fc23adc6
commit 9ec92a9082
6 changed files with 603 additions and 0 deletions

View File

@@ -106,6 +106,27 @@ public static class FocasCapabilityMatrix
_ => int.MaxValue,
};
/// <summary>
/// Whether the FOCAS driver should expose the per-device <c>Tooling/</c>
/// fixed-tree subfolder for a given <paramref name="series"/>. Backed by
/// <c>cnc_rdtnum</c>, which is documented for every modern Fanuc series
/// (0i / 16i / 30i families) — defaulting to <c>true</c>. The capability
/// hook exists so a future controller without <c>cnc_rdtnum</c> can opt
/// out without touching the driver. <see cref="FocasCncSeries.Unknown"/>
/// stays permissive (matches the modal / override fixed-tree precedent in
/// issue #259). Issue #260.
/// </summary>
public static bool SupportsTooling(FocasCncSeries series) => true;
/// <summary>
/// Whether the FOCAS driver should expose the per-device <c>Offsets/</c>
/// fixed-tree subfolder for a given <paramref name="series"/>. Backed by
/// <c>cnc_rdzofs(n=1..6)</c> for the standard G54..G59 surfaces; extended
/// G54.1 P1..P48 surfaces are deferred to a follow-up. Same permissive
/// policy as <see cref="SupportsTooling"/>. Issue #260.
/// </summary>
public static bool SupportsWorkOffsets(FocasCncSeries series) => true;
private static string? ValidateMacro(FocasCncSeries series, int number)
{
var (min, max) = MacroRange(series);

View File

@@ -32,6 +32,10 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, (string Host, string Field)> _overrideNodesByName =
new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, string> _toolingNodesByName =
new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, (string Host, string Slot, string Axis)> _offsetNodesByName =
new(StringComparer.OrdinalIgnoreCase);
private DriverHealth _health = new(DriverState.Unknown, null, null);
/// <summary>
@@ -69,6 +73,24 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// </summary>
private static readonly string[] OverrideFieldNames = ["Feed", "Rapid", "Spindle", "Jog"];
/// <summary>
/// Names of the standard work-coordinate offset slots surfaced under
/// <c>Offsets/</c> per device — G54..G59 from <c>cnc_rdzofs(n=1..6)</c>
/// (issue #260). Extended G54.1 P1..P48 surfaces are deferred to a follow-up
/// PR because <c>cnc_rdzofsr</c> uses a different range surface.
/// </summary>
private static readonly string[] WorkOffsetSlotNames =
[
"G54", "G55", "G56", "G57", "G58", "G59",
];
/// <summary>
/// Axis columns surfaced under each <c>Offsets/{slot}/</c> folder. Per the F1-d
/// plan a fixed 3-axis (X/Y/Z) view is used; lathes / mills with extra rotational
/// offsets get those columns exposed as 0.0 until a follow-up extends the surface.
/// </summary>
private static readonly string[] WorkOffsetAxisNames = ["X", "Y", "Z"];
public event EventHandler<DataChangeEventArgs>? OnDataChange;
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
@@ -141,6 +163,28 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
(device.Options.HostAddress, field);
}
}
// Tooling/CurrentTool — single Int16 node per device (issue #260). Tool
// life + active offset index are deferred per the F1-d plan; they need
// ODBTLIFE* unions whose shape varies per series.
if (FocasCapabilityMatrix.SupportsTooling(device.Options.Series))
{
_toolingNodesByName[ToolingReferenceFor(device.Options.HostAddress, "CurrentTool")] =
device.Options.HostAddress;
}
// Offsets/{G54..G59}/{X|Y|Z} — fixed 3-axis view of the standard work-
// coordinate offsets (issue #260). Capability matrix gates by series so
// legacy CNCs that don't support cnc_rdzofs don't produce the subtree.
if (FocasCapabilityMatrix.SupportsWorkOffsets(device.Options.Series))
{
foreach (var slot in WorkOffsetSlotNames)
foreach (var axis in WorkOffsetAxisNames)
{
_offsetNodesByName[OffsetReferenceFor(device.Options.HostAddress, slot, axis)] =
(device.Options.HostAddress, slot, axis);
}
}
}
if (_options.Probe.Enabled)
@@ -184,6 +228,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
_productionNodesByName.Clear();
_modalNodesByName.Clear();
_overrideNodesByName.Clear();
_toolingNodesByName.Clear();
_offsetNodesByName.Clear();
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
}
@@ -237,6 +283,22 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
continue;
}
// Fixed-tree Tooling/CurrentTool — served from cached cnc_rdtnum snapshot
// refreshed on the probe tick (issue #260). No wire call here.
if (_toolingNodesByName.TryGetValue(reference, out var toolingHost))
{
results[i] = ReadToolingField(toolingHost, "CurrentTool", now);
continue;
}
// Fixed-tree Offsets/{slot}/{axis} — served from cached cnc_rdzofs(1..6)
// snapshot refreshed on the probe tick (issue #260). No wire call here.
if (_offsetNodesByName.TryGetValue(reference, out var offsetKey))
{
results[i] = ReadOffsetField(offsetKey.Host, offsetKey.Slot, offsetKey.Axis, now);
continue;
}
if (!_tagsByName.TryGetValue(reference, out var def))
{
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
@@ -435,6 +497,50 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
WriteIdempotent: false));
}
}
// Fixed-tree Tooling/ subfolder — single Int16 CurrentTool node from
// cnc_rdtnum (issue #260). Tool life + active offset index are deferred
// per the F1-d plan because the FWLIB ODBTLIFE* unions vary per series.
if (FocasCapabilityMatrix.SupportsTooling(device.Series))
{
var toolingFolder = deviceFolder.Folder("Tooling", "Tooling");
var toolingRef = ToolingReferenceFor(device.HostAddress, "CurrentTool");
toolingFolder.Variable("CurrentTool", "CurrentTool", new DriverAttributeInfo(
FullName: toolingRef,
DriverDataType: DriverDataType.Int16,
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: false));
}
// Fixed-tree Offsets/ subfolder — G54..G59 each with X/Y/Z Float64 axes
// from cnc_rdzofs(n=1..6) (issue #260). Capability matrix gates the surface
// by series so legacy controllers without cnc_rdzofs support don't expose
// dead nodes. Extended G54.1 P1..P48 surfaces are deferred to a follow-up.
if (FocasCapabilityMatrix.SupportsWorkOffsets(device.Series))
{
var offsetsFolder = deviceFolder.Folder("Offsets", "Offsets");
foreach (var slot in WorkOffsetSlotNames)
{
var slotFolder = offsetsFolder.Folder(slot, slot);
foreach (var axis in WorkOffsetAxisNames)
{
var fullRef = OffsetReferenceFor(device.HostAddress, slot, axis);
slotFolder.Variable(axis, axis, new DriverAttributeInfo(
FullName: fullRef,
DriverDataType: DriverDataType.Float64,
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: false));
}
}
}
}
return Task.CompletedTask;
}
@@ -451,6 +557,12 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
private static string OverrideReferenceFor(string hostAddress, string field) =>
$"{hostAddress}::Override/{field}";
private static string ToolingReferenceFor(string hostAddress, string field) =>
$"{hostAddress}::Tooling/{field}";
private static string OffsetReferenceFor(string hostAddress, string slot, string axis) =>
$"{hostAddress}::Offsets/{slot}/{axis}";
private static ushort? OverrideParamFor(FocasOverrideParameters p, string field) => field switch
{
"Feed" => p.FeedParam,
@@ -566,6 +678,28 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
state.LastOverrideUtc = DateTime.UtcNow;
}
}
// Tooling/CurrentTool + Offsets/{G54..G59}/{X|Y|Z} — same best-
// effort policy as the other fixed-tree caches (issue #260). A
// null result leaves the previous good snapshot in place so reads
// keep serving until the next successful refresh.
if (FocasCapabilityMatrix.SupportsTooling(state.Options.Series))
{
var tooling = await client.GetToolingAsync(ct).ConfigureAwait(false);
if (tooling is not null)
{
state.LastTooling = tooling;
state.LastToolingUtc = DateTime.UtcNow;
}
}
if (FocasCapabilityMatrix.SupportsWorkOffsets(state.Options.Series))
{
var offsets = await client.GetWorkOffsetsAsync(ct).ConfigureAwait(false);
if (offsets is not null)
{
state.LastWorkOffsets = offsets;
state.LastWorkOffsetsUtc = DateTime.UtcNow;
}
}
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
@@ -630,6 +764,43 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
device.LastOverrideUtc, now);
}
private DataValueSnapshot ReadToolingField(string hostAddress, string field, DateTime now)
{
if (!_devices.TryGetValue(hostAddress, out var device))
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
if (device.LastTooling is not { } snap)
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
return field switch
{
"CurrentTool" => new DataValueSnapshot(snap.CurrentTool, FocasStatusMapper.Good,
device.LastToolingUtc, now),
_ => new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now),
};
}
private DataValueSnapshot ReadOffsetField(string hostAddress, string slot, string axis, DateTime now)
{
if (!_devices.TryGetValue(hostAddress, out var device))
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
if (device.LastWorkOffsets is not { } snap)
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
var match = snap.Offsets.FirstOrDefault(o =>
string.Equals(o.Name, slot, StringComparison.OrdinalIgnoreCase));
if (match is null)
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
var value = axis switch
{
"X" => (double?)match.X,
"Y" => match.Y,
"Z" => match.Z,
_ => null,
};
if (value is null)
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
return new DataValueSnapshot(value.Value, FocasStatusMapper.Good,
device.LastWorkOffsetsUtc, now);
}
private void TransitionDeviceState(DeviceState state, HostState newState)
{
HostState old;
@@ -717,6 +888,23 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
public FocasOverrideInfo? LastOverride { get; set; }
public DateTime LastOverrideUtc { get; set; }
/// <summary>
/// Cached <c>cnc_rdtnum</c> snapshot — current tool number — refreshed on
/// every probe tick. Reads of <c>Tooling/CurrentTool</c> serve from this
/// cache so they don't pile extra wire traffic on top of user-driven
/// reads (issue #260).
/// </summary>
public FocasToolingInfo? LastTooling { get; set; }
public DateTime LastToolingUtc { get; set; }
/// <summary>
/// Cached <c>cnc_rdzofs(1..6)</c> snapshot — G54..G59 work-coordinate
/// offsets — refreshed on every probe tick. Reads of
/// <c>Offsets/{slot}/{X|Y|Z}</c> serve from this cache (issue #260).
/// </summary>
public FocasWorkOffsetsInfo? LastWorkOffsets { get; set; }
public DateTime LastWorkOffsetsUtc { get; set; }
public void DisposeClient()
{
Client?.Dispose();

View File

@@ -242,6 +242,68 @@ internal sealed class FwlibFocasClient : IFocasClient
return TryReadInt16Param(param.Value, out var v) ? v : null;
}
public Task<FocasToolingInfo?> GetToolingAsync(CancellationToken cancellationToken)
{
if (!_connected) return Task.FromResult<FocasToolingInfo?>(null);
var buf = new FwlibNative.IODBTNUM();
var ret = FwlibNative.RdToolNumber(_handle, ref buf);
if (ret != 0) return Task.FromResult<FocasToolingInfo?>(null);
// FWLIB returns long; clamp to short for the surfaced Int16 (T-codes
// overflowing 32767 are vanishingly rare on Fanuc tool tables).
var t = buf.Data;
if (t > short.MaxValue) t = short.MaxValue;
else if (t < short.MinValue) t = short.MinValue;
return Task.FromResult<FocasToolingInfo?>(new FocasToolingInfo((short)t));
}
public Task<FocasWorkOffsetsInfo?> GetWorkOffsetsAsync(CancellationToken cancellationToken)
{
if (!_connected) return Task.FromResult<FocasWorkOffsetsInfo?>(null);
// 1..6 = G54..G59. Extended G54.1 P1..P48 use cnc_rdzofsr and are deferred.
// Pass axis=-1 so FWLIB fills every axis it has; we read the first 3 (X/Y/Z).
// Length = 4-byte header + 3 axes * 10-byte OFSB = 34. We request 4 + 8*10 = 84
// (the buffer ceiling) so a CNC with more axes still completes the call.
var slots = new List<FocasWorkOffset>(6);
string[] names = ["G54", "G55", "G56", "G57", "G58", "G59"];
for (short n = 1; n <= 6; n++)
{
var buf = new FwlibNative.IODBZOFS { Data = new byte[80] };
var ret = FwlibNative.RdWorkOffset(_handle, n, axis: -1, length: 4 + 8 * 10, ref buf);
if (ret != 0)
{
// Best-effort — a single-slot failure leaves the slot at 0.0; the cache
// still publishes so reads on the other offsets serve Good. The probe
// loop will retry on the next tick.
slots.Add(new FocasWorkOffset(names[n - 1], 0, 0, 0));
continue;
}
slots.Add(new FocasWorkOffset(
Name: names[n - 1],
X: DecodeOfsbAxis(buf.Data, axisIndex: 0),
Y: DecodeOfsbAxis(buf.Data, axisIndex: 1),
Z: DecodeOfsbAxis(buf.Data, axisIndex: 2)));
}
return Task.FromResult<FocasWorkOffsetsInfo?>(new FocasWorkOffsetsInfo(slots));
}
/// <summary>
/// Decode one OFSB axis block from a <c>cnc_rdzofs</c> data buffer. Each axis
/// occupies 10 bytes per <c>fwlib32.h</c>: <c>int data</c> + <c>short dec</c> +
/// <c>short unit</c> + <c>short disp</c>. The user-facing offset is
/// <c>data / 10^dec</c> — same convention as <c>cnc_rdmacro</c>.
/// </summary>
internal static double DecodeOfsbAxis(byte[] data, int axisIndex)
{
const int blockSize = 10;
var offset = axisIndex * blockSize;
if (offset + blockSize > data.Length) return 0;
var raw = BinaryPrimitives.ReadInt32LittleEndian(data.AsSpan(offset, 4));
var dec = BinaryPrimitives.ReadInt16LittleEndian(data.AsSpan(offset + 4, 2));
if (dec < 0 || dec > 9) dec = 0;
return raw / Math.Pow(10.0, dec);
}
// ---- PMC ----
private (object? value, uint status) ReadPmc(FocasAddress address, FocasDataType type)

View File

@@ -112,6 +112,35 @@ internal static class FwlibNative
[DllImport(Library, EntryPoint = "cnc_modal", ExactSpelling = true)]
public static extern short Modal(ushort handle, short type, short block, ref ODBMDL buffer);
// ---- Tooling ----
/// <summary>
/// <c>cnc_rdtnum</c> — read the currently selected tool number. Returns
/// <c>EW_OK</c> + populates <see cref="IODBTNUM.Data"/> with the active T-code.
/// Tool life + current offset index reads (<c>cnc_rdtlinfo</c>/<c>cnc_rdtlsts</c>/
/// <c>cnc_rdtofs</c>) are deferred per the F1-d plan — those calls use ODBTLIFE*
/// unions whose shape varies per series.
/// </summary>
[DllImport(Library, EntryPoint = "cnc_rdtnum", ExactSpelling = true)]
public static extern short RdToolNumber(ushort handle, ref IODBTNUM buffer);
// ---- Work coordinate offsets ----
/// <summary>
/// <c>cnc_rdzofs</c> — read one work-coordinate offset slot. <paramref name="number"/>:
/// 1..6 = G54..G59 (standard). Extended <c>G54.1 P1..P48</c> use <c>cnc_rdzofsr</c>
/// and are deferred. <paramref name="axis"/>: -1 = all axes returned, 1..N = single
/// axis. <paramref name="length"/>: 12 + (N axes * 8) — we request -1 and let FWLIB
/// fill up to <see cref="IODBZOFS.Data"/>'s 8-axis ceiling.
/// </summary>
[DllImport(Library, EntryPoint = "cnc_rdzofs", ExactSpelling = true)]
public static extern short RdWorkOffset(
ushort handle,
short number,
short axis,
short length,
ref IODBZOFS buffer);
// ---- Structs ----
/// <summary>
@@ -180,6 +209,38 @@ internal static class FwlibNative
public byte[] Data;
}
/// <summary>
/// IODBTNUM — current tool number read buffer. <see cref="Data"/> holds the active
/// T-code (Fanuc reference uses <c>long</c>; we narrow to <c>short</c> on the
/// managed side because <see cref="FocasToolingInfo.CurrentTool"/> surfaces as
/// <c>Int16</c>). Issue #260, F1-d.
/// </summary>
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct IODBTNUM
{
public short Datano;
public short Type;
public int Data;
}
/// <summary>
/// IODBZOFS — work-coordinate offset read buffer. 4-byte header + per-axis
/// <c>OFSB</c> blocks (8 bytes each: 4-byte signed integer <c>data</c> + 2-byte
/// <c>dec</c> decimal-point count + 2-byte <c>unit</c> + 2-byte <c>disp</c>).
/// We marshal a fixed ceiling of 8 axes (= 64 bytes); the managed side reads
/// only the first 3 (X / Y / Z) per the F1-d effort sizing.
/// </summary>
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct IODBZOFS
{
public short Datano;
public short Type;
// Up to 8 axes * 8 bytes per OFSB = 64 bytes. Each block: int data, short dec,
// short unit, short disp (10 bytes per fwlib32.h). We size for the worst case.
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 80)]
public byte[] Data;
}
/// <summary>ODBST — CNC status info. Machine state, alarm flags, automatic / edit mode.</summary>
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct ODBST

View File

@@ -91,6 +91,29 @@ public interface IFocasClient : IDisposable
Task<FocasOverrideInfo?> GetOverrideAsync(
FocasOverrideParameters parameters, CancellationToken cancellationToken)
=> Task.FromResult<FocasOverrideInfo?>(null);
/// <summary>
/// Read the current tool number via <c>cnc_rdtnum</c>. Surfaced on the FOCAS driver's
/// <c>Tooling/</c> fixed-tree per device (issue #260). Tool life + current offset
/// index are deferred — <c>cnc_rdtlinfo</c>/<c>cnc_rdtlsts</c> vary heavily across
/// CNC series + the FWLIB <c>ODBTLIFE*</c> unions need per-series shape handling
/// that exceeds the L-sized scope of this PR. Returns <c>null</c> when the wire
/// client cannot supply the snapshot (e.g. older transport variant).
/// </summary>
Task<FocasToolingInfo?> GetToolingAsync(CancellationToken cancellationToken)
=> Task.FromResult<FocasToolingInfo?>(null);
/// <summary>
/// Read the standard G54..G59 work-coordinate offsets via
/// <c>cnc_rdzofs(handle, n=1..6)</c>. Returns one <see cref="FocasWorkOffset"/>
/// per slot (issue #260). Extended G54.1 P1..P48 offsets are deferred — they use
/// a different FOCAS call (<c>cnc_rdzofsr</c>) + different range handling. Each
/// offset surfaces a fixed X/Y/Z view; lathes/mills with extra rotational axes
/// have those columns reported as 0.0. Returns <c>null</c> when the wire client
/// cannot supply the snapshot.
/// </summary>
Task<FocasWorkOffsetsInfo?> GetWorkOffsetsAsync(CancellationToken cancellationToken)
=> Task.FromResult<FocasWorkOffsetsInfo?>(null);
}
/// <summary>
@@ -164,6 +187,34 @@ public sealed record FocasOverrideInfo(
short? Spindle,
short? Jog);
/// <summary>
/// Snapshot of the currently selected tool number (issue #260). Sourced from
/// <c>cnc_rdtnum</c>. The active offset index is deferred — most modern CNCs
/// interleave tool number and offset H/D codes through different FOCAS calls
/// (<c>cnc_rdtofs</c> against a specific slot) and the issue body permits
/// surfacing tool number alone in the first cut. Surfaced as <c>Int16</c> in
/// the OPC UA address space.
/// </summary>
public sealed record FocasToolingInfo(short CurrentTool);
/// <summary>
/// One work-coordinate offset slot (G54..G59). Three axis columns are surfaced
/// (X / Y / Z) — the issue body permits a fixed 3-axis view because lathes and
/// mills typically don't expose extended rotational offsets via the standard
/// <c>cnc_rdzofs</c> call. Extended <c>G54.1 Pn</c> offsets via <c>cnc_rdzofsr</c>
/// are deferred to a follow-up PR. Values surfaced as <c>Float64</c> in microns
/// converted to user units (the FWLIB <c>data</c> field is an integer + decimal-
/// point count, decoded the same way <c>cnc_rdmacro</c> values are).
/// </summary>
public sealed record FocasWorkOffset(string Name, double X, double Y, double Z);
/// <summary>
/// Snapshot of the six standard work-coordinate offsets (G54..G59). Refreshed on
/// the probe tick + served from the per-device cache by reads of the
/// <c>Offsets/{name}/{X|Y|Z}</c> fixed-tree nodes (issue #260).
/// </summary>
public sealed record FocasWorkOffsetsInfo(IReadOnlyList<FocasWorkOffset> Offsets);
/// <summary>Factory for <see cref="IFocasClient"/>s. One client per configured device.</summary>
public interface IFocasClientFactory
{

View File

@@ -0,0 +1,220 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
[Trait("Category", "Unit")]
public sealed class FocasToolingOffsetsFixedTreeTests
{
private const string Host = "focas://10.0.0.7:8193";
/// <summary>
/// Variant of <see cref="FakeFocasClient"/> that returns configurable
/// <see cref="FocasToolingInfo"/> + <see cref="FocasWorkOffsetsInfo"/> snapshots
/// for the F1-d Tooling/CurrentTool + Offsets/ fixed-tree (issue #260).
/// </summary>
private sealed class ToolingAwareFakeFocasClient : FakeFocasClient, IFocasClient
{
public FocasToolingInfo? Tooling { get; set; }
public FocasWorkOffsetsInfo? WorkOffsets { get; set; }
Task<FocasToolingInfo?> IFocasClient.GetToolingAsync(CancellationToken ct) =>
Task.FromResult(Tooling);
Task<FocasWorkOffsetsInfo?> IFocasClient.GetWorkOffsetsAsync(CancellationToken ct) =>
Task.FromResult(WorkOffsets);
}
[Fact]
public async Task DiscoverAsync_emits_Tooling_folder_with_CurrentTool_node()
{
var builder = new RecordingBuilder();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host, DeviceName: "Mill-1")],
Tags = [],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-tooling", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Folders.ShouldContain(f => f.BrowseName == "Tooling" && f.DisplayName == "Tooling");
var toolingVars = builder.Variables.Where(v =>
v.Info.FullName.Contains("::Tooling/")).ToList();
toolingVars.Count.ShouldBe(1);
var node = toolingVars.Single();
node.BrowseName.ShouldBe("CurrentTool");
node.Info.DriverDataType.ShouldBe(DriverDataType.Int16);
node.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
node.Info.FullName.ShouldBe($"{Host}::Tooling/CurrentTool");
}
[Fact]
public async Task DiscoverAsync_emits_Offsets_folder_with_G54_to_G59_each_with_3_axes()
{
// Six standard slots (G54..G59) * three axes (X/Y/Z) = 18 Float64 nodes per
// device. Extended G54.1 P1..P48 deferred per the F1-d plan.
var builder = new RecordingBuilder();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host, DeviceName: "Mill-1")],
Tags = [],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-offsets", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Folders.ShouldContain(f => f.BrowseName == "Offsets");
string[] expectedSlots = ["G54", "G55", "G56", "G57", "G58", "G59"];
foreach (var slot in expectedSlots)
builder.Folders.ShouldContain(f => f.BrowseName == slot);
var offsetVars = builder.Variables.Where(v =>
v.Info.FullName.Contains("::Offsets/")).ToList();
offsetVars.Count.ShouldBe(6 * 3);
foreach (var slot in expectedSlots)
foreach (var axis in new[] { "X", "Y", "Z" })
{
var fullRef = $"{Host}::Offsets/{slot}/{axis}";
var node = offsetVars.SingleOrDefault(v => v.Info.FullName == fullRef);
node.Info.DriverDataType.ShouldBe(DriverDataType.Float64);
node.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
}
}
[Fact]
public async Task ReadAsync_serves_Tooling_and_Offsets_fields_from_cached_snapshot()
{
var fake = new ToolingAwareFakeFocasClient
{
Tooling = new FocasToolingInfo(CurrentTool: 17),
WorkOffsets = new FocasWorkOffsetsInfo(
[
new FocasWorkOffset("G54", X: 100.5, Y: 200.25, Z: -50.0),
new FocasWorkOffset("G55", X: 0, Y: 0, Z: 0),
new FocasWorkOffset("G56", X: 0, Y: 0, Z: 0),
new FocasWorkOffset("G57", X: 0, Y: 0, Z: 0),
new FocasWorkOffset("G58", X: 0, Y: 0, Z: 0),
new FocasWorkOffset("G59", X: 1, Y: 2, Z: 3),
]),
};
var factory = new FakeFocasClientFactory { Customise = () => fake };
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Tags = [],
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50) },
}, "drv-tooling-read", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
// Wait for at least one probe tick to populate both caches.
await WaitForAsync(async () =>
{
var snap = (await drv.ReadAsync(
[$"{Host}::Tooling/CurrentTool"], CancellationToken.None)).Single();
return snap.StatusCode == FocasStatusMapper.Good;
}, TimeSpan.FromSeconds(3));
var refs = new[]
{
$"{Host}::Tooling/CurrentTool",
$"{Host}::Offsets/G54/X",
$"{Host}::Offsets/G54/Y",
$"{Host}::Offsets/G54/Z",
$"{Host}::Offsets/G59/X",
};
var snaps = await drv.ReadAsync(refs, CancellationToken.None);
snaps[0].Value.ShouldBe((short)17);
snaps[1].Value.ShouldBe(100.5);
snaps[2].Value.ShouldBe(200.25);
snaps[3].Value.ShouldBe(-50.0);
snaps[4].Value.ShouldBe(1.0);
foreach (var s in snaps) s.StatusCode.ShouldBe(FocasStatusMapper.Good);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task ReadAsync_returns_BadCommunicationError_when_caches_are_empty()
{
// Probe disabled — neither tooling nor offsets caches populate; the nodes
// still resolve as known references but report Bad until the first poll.
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Tags = [],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-empty-tooling", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
var snaps = await drv.ReadAsync(
[$"{Host}::Tooling/CurrentTool", $"{Host}::Offsets/G54/X"], CancellationToken.None);
snaps[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
snaps[1].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
}
[Fact]
public async Task FwlibFocasClient_GetTooling_and_GetWorkOffsets_return_null_when_disconnected()
{
// Construction is licence-safe (no DLL load); the unconnected client must
// short-circuit before P/Invoke. Returns null → driver leaves the cache
// untouched, matching the policy in f1a/f1b/f1c.
var client = new FwlibFocasClient();
(await client.GetToolingAsync(CancellationToken.None)).ShouldBeNull();
(await client.GetWorkOffsetsAsync(CancellationToken.None)).ShouldBeNull();
}
[Fact]
public void DecodeOfsbAxis_applies_decimal_point_count_like_macro_decode()
{
// Layout per fwlib32.h: int data, short dec, short unit, short disp = 10 bytes.
// Three axes (X=12345 / dec=3 = 12.345; Y=-500 / dec=2 = -5.00; Z=0 / dec=0 = 0).
var buf = new byte[80];
WriteAxis(buf, 0, raw: 12345, dec: 3);
WriteAxis(buf, 1, raw: -500, dec: 2);
WriteAxis(buf, 2, raw: 0, dec: 0);
FwlibFocasClient.DecodeOfsbAxis(buf, 0).ShouldBe(12.345, tolerance: 1e-9);
FwlibFocasClient.DecodeOfsbAxis(buf, 1).ShouldBe(-5.0, tolerance: 1e-9);
FwlibFocasClient.DecodeOfsbAxis(buf, 2).ShouldBe(0.0, tolerance: 1e-9);
}
private static void WriteAxis(byte[] buf, int axisIndex, int raw, short dec)
{
var offset = axisIndex * 10;
System.Buffers.Binary.BinaryPrimitives.WriteInt32LittleEndian(buf.AsSpan(offset, 4), raw);
System.Buffers.Binary.BinaryPrimitives.WriteInt16LittleEndian(buf.AsSpan(offset + 4, 2), dec);
}
private static async Task WaitForAsync(Func<Task<bool>> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (!await condition() && DateTime.UtcNow < deadline)
await Task.Delay(20);
}
private sealed class RecordingBuilder : IAddressSpaceBuilder
{
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{ Folders.Add((browseName, displayName)); return this; }
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
public void AddProperty(string _, DriverDataType __, object? ___) { }
private sealed class Handle(string fullRef) : IVariableHandle
{
public string FullReference => fullRef;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
}
private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
}
}