From 9ec92a908210bdee72e91c94c03891eb78e8ab60 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 25 Apr 2026 14:37:51 -0400 Subject: [PATCH] =?UTF-8?q?Auto:=20focas-f1d=20=E2=80=94=20Tool=20number?= =?UTF-8?q?=20+=20work=20coordinate=20offsets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #260 --- .../FocasCapabilityMatrix.cs | 21 ++ .../FocasDriver.cs | 188 +++++++++++++++ .../FwlibFocasClient.cs | 62 +++++ .../FwlibNative.cs | 61 +++++ .../IFocasClient.cs | 51 ++++ .../FocasToolingOffsetsFixedTreeTests.cs | 220 ++++++++++++++++++ 6 files changed, 603 insertions(+) create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasToolingOffsetsFixedTreeTests.cs diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasCapabilityMatrix.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasCapabilityMatrix.cs index 8007c8c..5ca176a 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasCapabilityMatrix.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasCapabilityMatrix.cs @@ -106,6 +106,27 @@ public static class FocasCapabilityMatrix _ => int.MaxValue, }; + /// + /// Whether the FOCAS driver should expose the per-device Tooling/ + /// fixed-tree subfolder for a given . Backed by + /// cnc_rdtnum, which is documented for every modern Fanuc series + /// (0i / 16i / 30i families) — defaulting to true. The capability + /// hook exists so a future controller without cnc_rdtnum can opt + /// out without touching the driver. + /// stays permissive (matches the modal / override fixed-tree precedent in + /// issue #259). Issue #260. + /// + public static bool SupportsTooling(FocasCncSeries series) => true; + + /// + /// Whether the FOCAS driver should expose the per-device Offsets/ + /// fixed-tree subfolder for a given . Backed by + /// cnc_rdzofs(n=1..6) for the standard G54..G59 surfaces; extended + /// G54.1 P1..P48 surfaces are deferred to a follow-up. Same permissive + /// policy as . Issue #260. + /// + public static bool SupportsWorkOffsets(FocasCncSeries series) => true; + private static string? ValidateMacro(FocasCncSeries series, int number) { var (min, max) = MacroRange(series); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs index d69f91d..3d3dbb7 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs @@ -32,6 +32,10 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _overrideNodesByName = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _toolingNodesByName = + new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _offsetNodesByName = + new(StringComparer.OrdinalIgnoreCase); private DriverHealth _health = new(DriverState.Unknown, null, null); /// @@ -69,6 +73,24 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, /// private static readonly string[] OverrideFieldNames = ["Feed", "Rapid", "Spindle", "Jog"]; + /// + /// Names of the standard work-coordinate offset slots surfaced under + /// Offsets/ per device — G54..G59 from cnc_rdzofs(n=1..6) + /// (issue #260). Extended G54.1 P1..P48 surfaces are deferred to a follow-up + /// PR because cnc_rdzofsr uses a different range surface. + /// + private static readonly string[] WorkOffsetSlotNames = + [ + "G54", "G55", "G56", "G57", "G58", "G59", + ]; + + /// + /// Axis columns surfaced under each Offsets/{slot}/ 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. + /// + private static readonly string[] WorkOffsetAxisNames = ["X", "Y", "Z"]; + public event EventHandler? OnDataChange; public event EventHandler? 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; } + /// + /// Cached cnc_rdtnum snapshot — current tool number — refreshed on + /// every probe tick. Reads of Tooling/CurrentTool serve from this + /// cache so they don't pile extra wire traffic on top of user-driven + /// reads (issue #260). + /// + public FocasToolingInfo? LastTooling { get; set; } + public DateTime LastToolingUtc { get; set; } + + /// + /// Cached cnc_rdzofs(1..6) snapshot — G54..G59 work-coordinate + /// offsets — refreshed on every probe tick. Reads of + /// Offsets/{slot}/{X|Y|Z} serve from this cache (issue #260). + /// + public FocasWorkOffsetsInfo? LastWorkOffsets { get; set; } + public DateTime LastWorkOffsetsUtc { get; set; } + public void DisposeClient() { Client?.Dispose(); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs index 626e519..a125c71 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs @@ -242,6 +242,68 @@ internal sealed class FwlibFocasClient : IFocasClient return TryReadInt16Param(param.Value, out var v) ? v : null; } + public Task GetToolingAsync(CancellationToken cancellationToken) + { + if (!_connected) return Task.FromResult(null); + var buf = new FwlibNative.IODBTNUM(); + var ret = FwlibNative.RdToolNumber(_handle, ref buf); + if (ret != 0) return Task.FromResult(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(new FocasToolingInfo((short)t)); + } + + public Task GetWorkOffsetsAsync(CancellationToken cancellationToken) + { + if (!_connected) return Task.FromResult(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(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(new FocasWorkOffsetsInfo(slots)); + } + + /// + /// Decode one OFSB axis block from a cnc_rdzofs data buffer. Each axis + /// occupies 10 bytes per fwlib32.h: int data + short dec + + /// short unit + short disp. The user-facing offset is + /// data / 10^dec — same convention as cnc_rdmacro. + /// + 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) diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs index c3a1852..2eb9e93 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs @@ -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 ---- + + /// + /// cnc_rdtnum — read the currently selected tool number. Returns + /// EW_OK + populates with the active T-code. + /// Tool life + current offset index reads (cnc_rdtlinfo/cnc_rdtlsts/ + /// cnc_rdtofs) are deferred per the F1-d plan — those calls use ODBTLIFE* + /// unions whose shape varies per series. + /// + [DllImport(Library, EntryPoint = "cnc_rdtnum", ExactSpelling = true)] + public static extern short RdToolNumber(ushort handle, ref IODBTNUM buffer); + + // ---- Work coordinate offsets ---- + + /// + /// cnc_rdzofs — read one work-coordinate offset slot. : + /// 1..6 = G54..G59 (standard). Extended G54.1 P1..P48 use cnc_rdzofsr + /// and are deferred. : -1 = all axes returned, 1..N = single + /// axis. : 12 + (N axes * 8) — we request -1 and let FWLIB + /// fill up to 's 8-axis ceiling. + /// + [DllImport(Library, EntryPoint = "cnc_rdzofs", ExactSpelling = true)] + public static extern short RdWorkOffset( + ushort handle, + short number, + short axis, + short length, + ref IODBZOFS buffer); + // ---- Structs ---- /// @@ -180,6 +209,38 @@ internal static class FwlibNative public byte[] Data; } + /// + /// IODBTNUM — current tool number read buffer. holds the active + /// T-code (Fanuc reference uses long; we narrow to short on the + /// managed side because surfaces as + /// Int16). Issue #260, F1-d. + /// + [StructLayout(LayoutKind.Sequential, Pack = 1)] + public struct IODBTNUM + { + public short Datano; + public short Type; + public int Data; + } + + /// + /// IODBZOFS — work-coordinate offset read buffer. 4-byte header + per-axis + /// OFSB blocks (8 bytes each: 4-byte signed integer data + 2-byte + /// dec decimal-point count + 2-byte unit + 2-byte disp). + /// 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. + /// + [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; + } + /// ODBST — CNC status info. Machine state, alarm flags, automatic / edit mode. [StructLayout(LayoutKind.Sequential, Pack = 1)] public struct ODBST diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs index 4259c01..0a3d555 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs @@ -91,6 +91,29 @@ public interface IFocasClient : IDisposable Task GetOverrideAsync( FocasOverrideParameters parameters, CancellationToken cancellationToken) => Task.FromResult(null); + + /// + /// Read the current tool number via cnc_rdtnum. Surfaced on the FOCAS driver's + /// Tooling/ fixed-tree per device (issue #260). Tool life + current offset + /// index are deferred — cnc_rdtlinfo/cnc_rdtlsts vary heavily across + /// CNC series + the FWLIB ODBTLIFE* unions need per-series shape handling + /// that exceeds the L-sized scope of this PR. Returns null when the wire + /// client cannot supply the snapshot (e.g. older transport variant). + /// + Task GetToolingAsync(CancellationToken cancellationToken) + => Task.FromResult(null); + + /// + /// Read the standard G54..G59 work-coordinate offsets via + /// cnc_rdzofs(handle, n=1..6). Returns one + /// per slot (issue #260). Extended G54.1 P1..P48 offsets are deferred — they use + /// a different FOCAS call (cnc_rdzofsr) + 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 null when the wire client + /// cannot supply the snapshot. + /// + Task GetWorkOffsetsAsync(CancellationToken cancellationToken) + => Task.FromResult(null); } /// @@ -164,6 +187,34 @@ public sealed record FocasOverrideInfo( short? Spindle, short? Jog); +/// +/// Snapshot of the currently selected tool number (issue #260). Sourced from +/// cnc_rdtnum. The active offset index is deferred — most modern CNCs +/// interleave tool number and offset H/D codes through different FOCAS calls +/// (cnc_rdtofs against a specific slot) and the issue body permits +/// surfacing tool number alone in the first cut. Surfaced as Int16 in +/// the OPC UA address space. +/// +public sealed record FocasToolingInfo(short CurrentTool); + +/// +/// 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 +/// cnc_rdzofs call. Extended G54.1 Pn offsets via cnc_rdzofsr +/// are deferred to a follow-up PR. Values surfaced as Float64 in microns +/// converted to user units (the FWLIB data field is an integer + decimal- +/// point count, decoded the same way cnc_rdmacro values are). +/// +public sealed record FocasWorkOffset(string Name, double X, double Y, double Z); + +/// +/// 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 +/// Offsets/{name}/{X|Y|Z} fixed-tree nodes (issue #260). +/// +public sealed record FocasWorkOffsetsInfo(IReadOnlyList Offsets); + /// Factory for s. One client per configured device. public interface IFocasClientFactory { diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasToolingOffsetsFixedTreeTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasToolingOffsetsFixedTreeTests.cs new file mode 100644 index 0000000..13e3b54 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasToolingOffsetsFixedTreeTests.cs @@ -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"; + + /// + /// Variant of that returns configurable + /// + snapshots + /// for the F1-d Tooling/CurrentTool + Offsets/ fixed-tree (issue #260). + /// + private sealed class ToolingAwareFakeFocasClient : FakeFocasClient, IFocasClient + { + public FocasToolingInfo? Tooling { get; set; } + public FocasWorkOffsetsInfo? WorkOffsets { get; set; } + + Task IFocasClient.GetToolingAsync(CancellationToken ct) => + Task.FromResult(Tooling); + + Task 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> 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) { } } + } +} -- 2.49.1