diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs index d11a751..70d3a63 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs @@ -40,6 +40,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _currentBlockNodesByName = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _diagnosticsNodesByName = + new(StringComparer.OrdinalIgnoreCase); private DriverHealth _health = new(DriverState.Unknown, null, null); /// @@ -95,6 +97,23 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, /// private static readonly string[] WorkOffsetAxisNames = ["X", "Y", "Z"]; + /// + /// Names of the five fixed-tree Diagnostics/ child nodes per device — runtime + /// counters surfaced for operator visibility (issue #262). Order matters for + /// deterministic discovery output. + /// + /// ReadCount (Int64) — successful probe ticks since init + /// ReadFailureCount (Int64) — failed probe ticks since init + /// LastErrorMessage (String) — text of the last probe / read failure + /// LastSuccessfulRead (DateTime) — UTC timestamp of the last good probe tick + /// ReconnectCount (Int64) — wire reconnects observed since init + /// + /// + private static readonly string[] DiagnosticsFieldNames = + [ + "ReadCount", "ReadFailureCount", "LastErrorMessage", "LastSuccessfulRead", "ReconnectCount", + ]; + public event EventHandler? OnDataChange; public event EventHandler? OnHostStatusChanged; @@ -197,6 +216,13 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, device.Options.HostAddress; _currentBlockNodesByName[CurrentBlockReferenceFor(device.Options.HostAddress)] = device.Options.HostAddress; + + // Diagnostics/{ReadCount, ReadFailureCount, LastErrorMessage, + // LastSuccessfulRead, ReconnectCount} — runtime counters surfaced for + // operator visibility (issue #262). Permissive across all CNC series. + foreach (var field in DiagnosticsFieldNames) + _diagnosticsNodesByName[DiagnosticsReferenceFor(device.Options.HostAddress, field)] = + (device.Options.HostAddress, field); } if (_options.Probe.Enabled) @@ -244,6 +270,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, _offsetNodesByName.Clear(); _messagesNodesByName.Clear(); _currentBlockNodesByName.Clear(); + _diagnosticsNodesByName.Clear(); _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null); } @@ -326,6 +353,14 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, continue; } + // Fixed-tree Diagnostics/ nodes — runtime counters maintained by the probe + // loop (issue #262). No wire call here. + if (_diagnosticsNodesByName.TryGetValue(reference, out var diagKey)) + { + results[i] = ReadDiagnosticsField(diagKey.Host, diagKey.Field, now); + continue; + } + if (!_tagsByName.TryGetValue(reference, out var def)) { results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now); @@ -599,10 +634,38 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, IsHistorized: false, IsAlarm: false, WriteIdempotent: false)); + + // Fixed-tree Diagnostics/ subfolder — 5 read-only counters surfaced for + // operator visibility (issue #262). ReadCount / ReadFailureCount / + // ReconnectCount are Int64; LastErrorMessage is String; + // LastSuccessfulRead is DateTime. Permissive across CNC series — every + // device gets the same shape. + var diagnosticsFolder = deviceFolder.Folder("Diagnostics", "Diagnostics"); + foreach (var field in DiagnosticsFieldNames) + { + var fullRef = DiagnosticsReferenceFor(device.HostAddress, field); + diagnosticsFolder.Variable(field, field, new DriverAttributeInfo( + FullName: fullRef, + DriverDataType: DiagnosticsFieldType(field), + IsArray: false, + ArrayDim: null, + SecurityClass: SecurityClassification.ViewOnly, + IsHistorized: false, + IsAlarm: false, + WriteIdempotent: false)); + } } return Task.CompletedTask; } + private static DriverDataType DiagnosticsFieldType(string field) => field switch + { + "ReadCount" or "ReadFailureCount" or "ReconnectCount" => DriverDataType.Int64, + "LastErrorMessage" => DriverDataType.String, + "LastSuccessfulRead" => DriverDataType.DateTime, + _ => DriverDataType.String, + }; + private static string StatusReferenceFor(string hostAddress, string field) => $"{hostAddress}::Status/{field}"; @@ -627,6 +690,9 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, private static string CurrentBlockReferenceFor(string hostAddress) => $"{hostAddress}::Program/CurrentBlock"; + private static string DiagnosticsReferenceFor(string hostAddress, string field) => + $"{hostAddress}::Diagnostics/{field}"; + private static ushort? OverrideParamFor(FocasOverrideParameters p, string field) => field switch { "Feed" => p.FeedParam, @@ -699,12 +765,23 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, while (!ct.IsCancellationRequested) { var success = false; + string? failureMessage = null; try { var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false); success = await client.ProbeAsync(ct).ConfigureAwait(false); if (success) { + // Refresh figure-scaling cache once per session (issue #262). The + // increment system rarely changes mid-session; re-reading every probe + // tick would waste a wire call. Best-effort — null result leaves the + // previous good map in place. + if (state.FigureScaling is null) + { + var fig = await client.GetFigureScalingAsync(ct).ConfigureAwait(false); + if (fig is not null) state.FigureScaling = fig; + } + // Refresh the cached ODBST status snapshot on every probe tick — this is // what the Status/ fixed-tree nodes serve from. Best-effort: a null result // (older IFocasClient impls without GetStatusAsync) just leaves the cache @@ -784,7 +861,27 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, } } catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; } - catch { /* connect-failure path already disposed + cleared the client */ } + catch (Exception ex) + { + failureMessage = ex.Message; + /* connect-failure path already disposed + cleared the client */ + } + + // Diagnostics counters refreshed per probe tick (issue #262). Successful + // ticks bump ReadCount + LastSuccessfulRead; failed ticks bump + // ReadFailureCount + LastErrorMessage. The reconnect counter is bumped in + // EnsureConnectedAsync's connect path so a wedged probe doesn't double-count. + if (success) + { + Interlocked.Increment(ref state.ReadCount); + state.LastSuccessfulReadUtc = DateTime.UtcNow; + } + else + { + Interlocked.Increment(ref state.ReadFailureCount); + if (!string.IsNullOrEmpty(failureMessage)) + state.LastErrorMessage = failureMessage; + } TransitionDeviceState(state, success ? HostState.Running : HostState.Stopped); @@ -908,6 +1005,46 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, device.LastCurrentBlockUtc, now); } + private DataValueSnapshot ReadDiagnosticsField(string hostAddress, string field, DateTime now) + { + if (!_devices.TryGetValue(hostAddress, out var device)) + return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now); + // Diagnostics counters are always Good — they're driver-internal state, not wire + // reads. LastSuccessfulRead surfaces DateTime.MinValue before the first probe + // tick rather than null because OPC UA's DateTime variant has no "unset" sentinel + // a generic client can interpret (issue #262). + object? value = field switch + { + "ReadCount" => Interlocked.Read(ref device.ReadCount), + "ReadFailureCount" => Interlocked.Read(ref device.ReadFailureCount), + "ReconnectCount" => Interlocked.Read(ref device.ReconnectCount), + "LastErrorMessage" => device.LastErrorMessage ?? string.Empty, + "LastSuccessfulRead" => device.LastSuccessfulReadUtc, + _ => null, + }; + if (value is null) + return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now); + return new DataValueSnapshot(value, FocasStatusMapper.Good, now, now); + } + + /// + /// Apply cnc_getfigure-derived decimal scaling to a raw position value. + /// Returns divided by 10^decimalPlaces when the + /// device has a cached scaling entry for AND + /// is on; otherwise + /// returns the raw value as a double. Forward-looking — surfaced for + /// future PRs that wire up Axes/{name}/AbsolutePosition etc. so they + /// don't need to re-derive the policy (issue #262). + /// + internal double ApplyFigureScaling(string hostAddress, string axisName, long raw) + { + if (!_options.FixedTree.ApplyFigureScaling) return raw; + if (!_devices.TryGetValue(hostAddress, out var device)) return raw; + if (device.FigureScaling is not { } map) return raw; + if (!map.TryGetValue(axisName, out var dec) || dec <= 0) return raw; + return raw / Math.Pow(10.0, dec); + } + private void TransitionDeviceState(DeviceState state, HostState newState) { HostState old; @@ -934,6 +1071,10 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, private async Task EnsureConnectedAsync(DeviceState device, CancellationToken ct) { if (device.Client is { IsConnected: true } c) return c; + // Reconnect counter bumps before the connect call — a successful first connect + // counts as one "establishment" so the field is non-zero from session start + // (issue #262, mirrors the convention from the AbCip / TwinCAT diagnostics). + Interlocked.Increment(ref device.ReconnectCount); device.Client ??= _clientFactory.Create(); try { @@ -1028,6 +1169,24 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, public FocasCurrentBlockInfo? LastCurrentBlock { get; set; } public DateTime LastCurrentBlockUtc { get; set; } + /// + /// Cached per-axis decimal-place counts from cnc_getfigure (issue #262). + /// Populated once per session (the increment system rarely changes mid-run); + /// served by when a future PR + /// surfaces position values that need scaling. Keys are axis names (or + /// fallback "axis{n}" until cnc_rdaxisname integration lands). + /// + public IReadOnlyDictionary? FigureScaling { get; set; } + + // Diagnostics counters per device — surfaced under Diagnostics/ subtree (issue + // #262). Public fields rather than properties so Interlocked.Increment can + // operate on them directly. Long-typed for the OPC UA Int64 surface. + public long ReadCount; + public long ReadFailureCount; + public long ReconnectCount; + public string? LastErrorMessage; + public DateTime LastSuccessfulReadUtc; + public void DisposeClient() { Client?.Dispose(); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverOptions.cs index 2e995c1..ad4f5c9 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverOptions.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverOptions.cs @@ -11,6 +11,34 @@ public sealed class FocasDriverOptions public IReadOnlyList Tags { get; init; } = []; public FocasProbeOptions Probe { get; init; } = new(); public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2); + + /// + /// Fixed-tree behaviour knobs (issue #262, plan PR F1-f). Carries the + /// ApplyFigureScaling toggle that gates the cnc_getfigure + /// decimal-place division applied to position values before publishing. + /// + public FocasFixedTreeOptions FixedTree { get; init; } = new(); +} + +/// +/// Per-driver fixed-tree options. New installs default +/// to true so position values surface in user units (mm / inch). Existing +/// deployments that already published raw scaled integers can flip this to false +/// for migration parity — the operator-facing concern is that switching the flag +/// mid-deployment changes the values clients see, so the migration path is +/// documentation-only (issue #262). +/// +public sealed record FocasFixedTreeOptions +{ + /// + /// When true (default), position values from cnc_absolute / + /// cnc_machine / cnc_relative / cnc_distance / + /// cnc_actf are divided by 10^decimalPlaces per axis using the + /// cnc_getfigure snapshot cached at probe time. When false, the + /// raw integer values are published unchanged — used for migrations from + /// older drivers that didn't apply the scaling. + /// + public bool ApplyFigureScaling { get; init; } = true; } /// diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs index 7b074b7..3bb84c3 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs @@ -318,6 +318,43 @@ internal sealed class FwlibFocasClient : IFocasClient new FocasCurrentBlockInfo(TrimAnsiPadding(buf.Data))); } + public Task?> GetFigureScalingAsync(CancellationToken cancellationToken) + { + if (!_connected) return Task.FromResult?>(null); + // kind=0 → position figures (absolute/relative/machine/distance share the same + // increment system per axis). cnc_rdaxisname is deferred — the wire impl keys + // by fallback "axis{n}" (1-based), the driver re-keys when it gains axis-name + // discovery in a follow-up. Issue #262, plan PR F1-f. + short count = 0; + var buf = new FwlibNative.IODBAXIS { Data = new byte[FwlibNative.MAX_AXIS * 8] }; + var ret = FwlibNative.GetFigure(_handle, kind: 0, ref count, ref buf); + if (ret != 0) return Task.FromResult?>(null); + return Task.FromResult?>(DecodeFigureScaling(buf.Data, count)); + } + + /// + /// Decode the per-axis decimal-place counts from a cnc_getfigure reply + /// buffer. Each axis entry per fwlib32.h is 8 bytes laid out as + /// short dec + short unit + 4 reserved bytes; we read only + /// dec. Keys are 1-based "axis{n}" placeholders — a follow-up + /// PR can rewire to cnc_rdaxisname once that surface lands without + /// changing the cache contract (issue #262). + /// + internal static IReadOnlyDictionary DecodeFigureScaling(byte[] data, short count) + { + var clamped = Math.Max((short)0, Math.Min(count, (short)FwlibNative.MAX_AXIS)); + var result = new Dictionary(clamped, StringComparer.OrdinalIgnoreCase); + for (var i = 0; i < clamped; i++) + { + var offset = i * 8; + if (offset + 2 > data.Length) break; + var dec = BinaryPrimitives.ReadInt16LittleEndian(data.AsSpan(offset, 2)); + if (dec < 0 || dec > 9) dec = 0; + result[$"axis{i + 1}"] = dec; + } + return result; + } + /// /// Decode + trim a Fanuc ANSI byte buffer. The CNC right-pads block text + opmsg /// bodies with nulls or spaces; trim them so the round-trip through the OPC UA diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs index 4ecfef8..dae1158 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs @@ -157,6 +157,25 @@ internal static class FwlibNative short length, ref OPMSG3 buffer); + // ---- Figure (per-axis decimal scaling) ---- + + /// + /// cnc_getfigure — read per-axis figure info (decimal-place counts + units). + /// : 0 = absolute / relative / machine position figures, + /// 1 = work-coord shift figures (per Fanuc reference). The reply struct holds + /// up to axis entries; the managed side reads the count + /// out via . Position values from cnc_absolute + /// / cnc_machine / cnc_relative / cnc_distance / cnc_actf + /// are scaled integers — divide by 10^figureinfo[axis].dec for user units + /// (issue #262, plan PR F1-f). + /// + [DllImport(Library, EntryPoint = "cnc_getfigure", ExactSpelling = true)] + public static extern short GetFigure( + ushort handle, + short kind, + ref short outCount, + ref IODBAXIS figureinfo); + // ---- Currently-executing block ---- /// @@ -298,6 +317,31 @@ internal static class FwlibNative public byte[] Data; } + /// + /// Maximum axis count per the FWLIB fwlib32.h ceiling for figure-info reads. + /// Real Fanuc CNCs cap at 8 simultaneous axes for most series; we marshal an + /// 8-entry array (matches ) so the call completes regardless + /// of the deployment's axis count (issue #262). + /// + public const int MAX_AXIS = 8; + + /// + /// IODBAXIS — per-axis figure info read buffer for cnc_getfigure. Each + /// axis entry carries the decimal-place count (dec) the CNC reports for + /// that axis's increment system + a unit code. The managed side reads the first + /// outCount entries returned by FWLIB; we marshal a fixed 8-entry ceiling + /// (issue #262, plan PR F1-f). + /// + [StructLayout(LayoutKind.Sequential, Pack = 1)] + public struct IODBAXIS + { + // Each entry per fwlib32.h is { short dec, short unit, short reserved, short reserved2 } + // = 8 bytes. 8 axes * 8 bytes = 64 bytes; we marshal a fixed byte buffer + decode on + // the managed side so axis-count growth doesn't churn the P/Invoke surface. + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 8 * 8)] + 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 dfdccc8..0b7d166 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs @@ -135,6 +135,19 @@ public interface IFocasClient : IDisposable /// Task GetCurrentBlockAsync(CancellationToken cancellationToken) => Task.FromResult(null); + + /// + /// Read the per-axis decimal-place counts via cnc_getfigure (issue #262). + /// Returned dictionary maps axis name (or fallback "axis{n}" when + /// cnc_rdaxisname isn't available) to the decimal-place count the CNC + /// reports for that axis's increment system. Cached at bootstrap by the driver + + /// applied to position values before publishing — raw integer / 10^decimalPlaces. + /// Returns null when the wire client cannot supply the snapshot (older + /// transport variant) — the driver leaves the cache untouched and falls back to + /// publishing raw values. + /// + Task?> GetFigureScalingAsync(CancellationToken cancellationToken) + => Task.FromResult?>(null); } /// diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasFigureScalingDiagnosticsTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasFigureScalingDiagnosticsTests.cs new file mode 100644 index 0000000..0aea3da --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasFigureScalingDiagnosticsTests.cs @@ -0,0 +1,273 @@ +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 FocasFigureScalingDiagnosticsTests +{ + private const string Host = "focas://10.0.0.7:8193"; + + /// + /// Variant of that returns configurable + /// per-axis figure scaling for the F1-f cache + diagnostics surface + /// (issue #262). + /// + private sealed class FigureAwareFakeFocasClient : FakeFocasClient, IFocasClient + { + public IReadOnlyDictionary? Scaling { get; set; } + + Task?> IFocasClient.GetFigureScalingAsync(CancellationToken ct) => + Task.FromResult(Scaling); + } + + [Fact] + public async Task DiscoverAsync_emits_Diagnostics_subtree_with_five_counters() + { + var builder = new RecordingBuilder(); + var drv = new FocasDriver(new FocasDriverOptions + { + Devices = [new FocasDeviceOptions(Host, DeviceName: "Mill-1")], + Tags = [], + Probe = new FocasProbeOptions { Enabled = false }, + }, "drv-diag", new FakeFocasClientFactory()); + await drv.InitializeAsync("{}", CancellationToken.None); + + await drv.DiscoverAsync(builder, CancellationToken.None); + + builder.Folders.ShouldContain(f => f.BrowseName == "Diagnostics" && f.DisplayName == "Diagnostics"); + var diagVars = builder.Variables.Where(v => + v.Info.FullName.Contains("::Diagnostics/")).ToList(); + diagVars.Count.ShouldBe(5); + + // Verify per-field types match the documented surface (Int64 counters, + // String error message, DateTime last-success timestamp). + diagVars.Single(v => v.BrowseName == "ReadCount") + .Info.DriverDataType.ShouldBe(DriverDataType.Int64); + diagVars.Single(v => v.BrowseName == "ReadFailureCount") + .Info.DriverDataType.ShouldBe(DriverDataType.Int64); + diagVars.Single(v => v.BrowseName == "ReconnectCount") + .Info.DriverDataType.ShouldBe(DriverDataType.Int64); + diagVars.Single(v => v.BrowseName == "LastErrorMessage") + .Info.DriverDataType.ShouldBe(DriverDataType.String); + diagVars.Single(v => v.BrowseName == "LastSuccessfulRead") + .Info.DriverDataType.ShouldBe(DriverDataType.DateTime); + + foreach (var v in diagVars) + v.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly); + } + + [Fact] + public async Task ReadAsync_publishes_diagnostics_counters_after_probe_ticks() + { + // Probe enabled — successful ticks bump ReadCount + LastSuccessfulRead; + // ReconnectCount bumps once on the initial connect (issue #262). + var fake = new FakeFocasClient { ProbeResult = true }; + 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(30) }, + }, "drv-diag-read", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + // Wait for at least 2 successful probe ticks so ReadCount > 0 deterministically. + await WaitForAsync(async () => + { + var snap = (await drv.ReadAsync( + [$"{Host}::Diagnostics/ReadCount"], CancellationToken.None)).Single(); + return snap.Value is long n && n >= 2; + }, TimeSpan.FromSeconds(3)); + + var refs = new[] + { + $"{Host}::Diagnostics/ReadCount", + $"{Host}::Diagnostics/ReadFailureCount", + $"{Host}::Diagnostics/ReconnectCount", + $"{Host}::Diagnostics/LastErrorMessage", + $"{Host}::Diagnostics/LastSuccessfulRead", + }; + var snaps = await drv.ReadAsync(refs, CancellationToken.None); + + ((long)snaps[0].Value!).ShouldBeGreaterThanOrEqualTo(2); + ((long)snaps[1].Value!).ShouldBe(0); // no failures on a healthy probe + ((long)snaps[2].Value!).ShouldBe(1); // one initial connect + snaps[3].Value.ShouldBe(string.Empty); + ((DateTime)snaps[4].Value!).ShouldBeGreaterThan(DateTime.MinValue); + + foreach (var s in snaps) s.StatusCode.ShouldBe(FocasStatusMapper.Good); + + await drv.ShutdownAsync(CancellationToken.None); + } + + [Fact] + public async Task ReadAsync_increments_ReadFailureCount_when_probe_returns_false() + { + // ProbeResult=false → success branch is skipped, ReadFailureCount bumps each + // tick. The connect itself succeeded so ReconnectCount is 1. + var fake = new FakeFocasClient { ProbeResult = false }; + 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(30) }, + }, "drv-diag-fail", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + await WaitForAsync(async () => + { + var snap = (await drv.ReadAsync( + [$"{Host}::Diagnostics/ReadFailureCount"], CancellationToken.None)).Single(); + return snap.Value is long n && n >= 2; + }, TimeSpan.FromSeconds(3)); + + var snaps = await drv.ReadAsync( + [$"{Host}::Diagnostics/ReadCount", $"{Host}::Diagnostics/ReadFailureCount"], + CancellationToken.None); + ((long)snaps[0].Value!).ShouldBe(0); + ((long)snaps[1].Value!).ShouldBeGreaterThanOrEqualTo(2); + + await drv.ShutdownAsync(CancellationToken.None); + } + + [Fact] + public async Task ApplyFigureScaling_divides_raw_position_by_ten_to_the_decimal_places() + { + // Cache populated via probe-tick GetFigureScalingAsync. ApplyFigureScaling + // default is true → rawValue / 10^dec for the named axis (issue #262). + var fake = new FigureAwareFakeFocasClient + { + Scaling = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["axis1"] = 3, // X-axis: 3 decimal places (mm * 1000) + ["axis2"] = 4, // Y-axis: 4 decimal places + }, + }; + 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(30) }, + }, "drv-fig", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + // Wait for the probe-tick path to populate the cache (one successful tick is + // enough — the figure-scaling read happens whenever the cache is null). + await WaitForAsync(async () => + { + var snap = (await drv.ReadAsync( + [$"{Host}::Diagnostics/ReadCount"], CancellationToken.None)).Single(); + return snap.Value is long n && n >= 1; + }, TimeSpan.FromSeconds(3)); + + // 100000 / 10^3 = 100.0 mm + drv.ApplyFigureScaling(Host, "axis1", 100000).ShouldBe(100.0); + // 250000 / 10^4 = 25.0 mm + drv.ApplyFigureScaling(Host, "axis2", 250000).ShouldBe(25.0); + // Unknown axis → raw value passes through. + drv.ApplyFigureScaling(Host, "axis3", 42).ShouldBe(42.0); + + await drv.ShutdownAsync(CancellationToken.None); + } + + [Fact] + public async Task ApplyFigureScaling_returns_raw_when_FixedTreeApplyFigureScaling_is_false() + { + // ApplyFigureScaling=false short-circuits before the cache lookup so the raw + // integer is published unchanged. Migration parity for deployments that already + // surfaced raw values from older drivers (issue #262). + var fake = new FigureAwareFakeFocasClient + { + Scaling = new Dictionary { ["axis1"] = 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(30) }, + FixedTree = new FocasFixedTreeOptions { ApplyFigureScaling = false }, + }, "drv-fig-off", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + await WaitForAsync(async () => + { + var snap = (await drv.ReadAsync( + [$"{Host}::Diagnostics/ReadCount"], CancellationToken.None)).Single(); + return snap.Value is long n && n >= 1; + }, TimeSpan.FromSeconds(3)); + + // Even though the cache has axis1 → 3 decimal places, ApplyFigureScaling=false + // means the raw value passes through unchanged. + drv.ApplyFigureScaling(Host, "axis1", 100000).ShouldBe(100000.0); + + await drv.ShutdownAsync(CancellationToken.None); + } + + [Fact] + public async Task FwlibFocasClient_GetFigureScaling_returns_null_when_disconnected() + { + // Construction is licence-safe (no DLL load); the unconnected client must + // short-circuit before P/Invoke so the driver leaves the cache untouched. + var client = new FwlibFocasClient(); + (await client.GetFigureScalingAsync(CancellationToken.None)).ShouldBeNull(); + } + + [Fact] + public void DecodeFigureScaling_extracts_per_axis_decimal_places_from_buffer() + { + // Build an IODBAXIS-shaped buffer: 3 axes, decimal places = 3, 4, 0. Per + // fwlib32.h each axis entry is { short dec, short unit, short reserved, + // short reserved2 } = 8 bytes; we only read dec. + var buf = new byte[FwlibNative.MAX_AXIS * 8]; + // Axis 1: dec=3 + buf[0] = 3; buf[1] = 0; + // Axis 2: dec=4 + buf[8] = 4; buf[9] = 0; + // Axis 3: dec=0 (already zero) + + var map = FwlibFocasClient.DecodeFigureScaling(buf, count: 3); + map.Count.ShouldBe(3); + map["axis1"].ShouldBe(3); + map["axis2"].ShouldBe(4); + map["axis3"].ShouldBe(0); + + // Out-of-range count clamps to MAX_AXIS so a malformed CNC reply doesn't + // overrun the buffer. + var clamped = FwlibFocasClient.DecodeFigureScaling(buf, count: 99); + clamped.Count.ShouldBe(FwlibNative.MAX_AXIS); + } + + 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) { } } + } +}