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) { } }
+ }
+}