@@ -106,6 +106,27 @@ public static class FocasCapabilityMatrix
|
|||||||
_ => int.MaxValue,
|
_ => 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)
|
private static string? ValidateMacro(FocasCncSeries series, int number)
|
||||||
{
|
{
|
||||||
var (min, max) = MacroRange(series);
|
var (min, max) = MacroRange(series);
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
new(StringComparer.OrdinalIgnoreCase);
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly Dictionary<string, (string Host, string Field)> _overrideNodesByName =
|
private readonly Dictionary<string, (string Host, string Field)> _overrideNodesByName =
|
||||||
new(StringComparer.OrdinalIgnoreCase);
|
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);
|
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -69,6 +73,24 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private static readonly string[] OverrideFieldNames = ["Feed", "Rapid", "Spindle", "Jog"];
|
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<DataChangeEventArgs>? OnDataChange;
|
||||||
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||||
|
|
||||||
@@ -141,6 +163,28 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
(device.Options.HostAddress, field);
|
(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)
|
if (_options.Probe.Enabled)
|
||||||
@@ -184,6 +228,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
_productionNodesByName.Clear();
|
_productionNodesByName.Clear();
|
||||||
_modalNodesByName.Clear();
|
_modalNodesByName.Clear();
|
||||||
_overrideNodesByName.Clear();
|
_overrideNodesByName.Clear();
|
||||||
|
_toolingNodesByName.Clear();
|
||||||
|
_offsetNodesByName.Clear();
|
||||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,6 +283,22 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
continue;
|
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))
|
if (!_tagsByName.TryGetValue(reference, out var def))
|
||||||
{
|
{
|
||||||
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||||
@@ -435,6 +497,50 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
WriteIdempotent: false));
|
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;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
@@ -451,6 +557,12 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
private static string OverrideReferenceFor(string hostAddress, string field) =>
|
private static string OverrideReferenceFor(string hostAddress, string field) =>
|
||||||
$"{hostAddress}::Override/{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
|
private static ushort? OverrideParamFor(FocasOverrideParameters p, string field) => field switch
|
||||||
{
|
{
|
||||||
"Feed" => p.FeedParam,
|
"Feed" => p.FeedParam,
|
||||||
@@ -566,6 +678,28 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
state.LastOverrideUtc = DateTime.UtcNow;
|
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; }
|
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
|
||||||
@@ -630,6 +764,43 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
device.LastOverrideUtc, now);
|
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)
|
private void TransitionDeviceState(DeviceState state, HostState newState)
|
||||||
{
|
{
|
||||||
HostState old;
|
HostState old;
|
||||||
@@ -717,6 +888,23 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
public FocasOverrideInfo? LastOverride { get; set; }
|
public FocasOverrideInfo? LastOverride { get; set; }
|
||||||
public DateTime LastOverrideUtc { 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()
|
public void DisposeClient()
|
||||||
{
|
{
|
||||||
Client?.Dispose();
|
Client?.Dispose();
|
||||||
|
|||||||
@@ -242,6 +242,68 @@ internal sealed class FwlibFocasClient : IFocasClient
|
|||||||
return TryReadInt16Param(param.Value, out var v) ? v : null;
|
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 ----
|
// ---- PMC ----
|
||||||
|
|
||||||
private (object? value, uint status) ReadPmc(FocasAddress address, FocasDataType type)
|
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)]
|
[DllImport(Library, EntryPoint = "cnc_modal", ExactSpelling = true)]
|
||||||
public static extern short Modal(ushort handle, short type, short block, ref ODBMDL buffer);
|
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 ----
|
// ---- Structs ----
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -180,6 +209,38 @@ internal static class FwlibNative
|
|||||||
public byte[] Data;
|
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>
|
/// <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
|
||||||
|
|||||||
@@ -91,6 +91,29 @@ public interface IFocasClient : IDisposable
|
|||||||
Task<FocasOverrideInfo?> GetOverrideAsync(
|
Task<FocasOverrideInfo?> GetOverrideAsync(
|
||||||
FocasOverrideParameters parameters, CancellationToken cancellationToken)
|
FocasOverrideParameters parameters, CancellationToken cancellationToken)
|
||||||
=> Task.FromResult<FocasOverrideInfo?>(null);
|
=> 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>
|
/// <summary>
|
||||||
@@ -164,6 +187,34 @@ public sealed record FocasOverrideInfo(
|
|||||||
short? Spindle,
|
short? Spindle,
|
||||||
short? Jog);
|
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>
|
/// <summary>Factory for <see cref="IFocasClient"/>s. One client per configured device.</summary>
|
||||||
public interface IFocasClientFactory
|
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