@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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) { } }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user