[focas] FOCAS — Figure scaling + diagnostics #330

Merged
dohertj2 merged 1 commits from auto/focas/F1-f into auto/driver-gaps 2026-04-25 15:04:07 -04:00
6 changed files with 555 additions and 1 deletions

View File

@@ -40,6 +40,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, string> _currentBlockNodesByName =
new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, (string Host, string Field)> _diagnosticsNodesByName =
new(StringComparer.OrdinalIgnoreCase);
private DriverHealth _health = new(DriverState.Unknown, null, null);
/// <summary>
@@ -95,6 +97,23 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// </summary>
private static readonly string[] WorkOffsetAxisNames = ["X", "Y", "Z"];
/// <summary>
/// Names of the five fixed-tree <c>Diagnostics/</c> child nodes per device — runtime
/// counters surfaced for operator visibility (issue #262). Order matters for
/// deterministic discovery output.
/// <list type="bullet">
/// <item><c>ReadCount</c> (Int64) — successful probe ticks since init</item>
/// <item><c>ReadFailureCount</c> (Int64) — failed probe ticks since init</item>
/// <item><c>LastErrorMessage</c> (String) — text of the last probe / read failure</item>
/// <item><c>LastSuccessfulRead</c> (DateTime) — UTC timestamp of the last good probe tick</item>
/// <item><c>ReconnectCount</c> (Int64) — wire reconnects observed since init</item>
/// </list>
/// </summary>
private static readonly string[] DiagnosticsFieldNames =
[
"ReadCount", "ReadFailureCount", "LastErrorMessage", "LastSuccessfulRead", "ReconnectCount",
];
public event EventHandler<DataChangeEventArgs>? OnDataChange;
public event EventHandler<HostStatusChangedEventArgs>? 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);
}
/// <summary>
/// Apply <c>cnc_getfigure</c>-derived decimal scaling to a raw position value.
/// Returns <paramref name="raw"/> divided by <c>10^decimalPlaces</c> when the
/// device has a cached scaling entry for <paramref name="axisName"/> AND
/// <see cref="FocasFixedTreeOptions.ApplyFigureScaling"/> is on; otherwise
/// returns the raw value as a <c>double</c>. Forward-looking — surfaced for
/// future PRs that wire up <c>Axes/{name}/AbsolutePosition</c> etc. so they
/// don't need to re-derive the policy (issue #262).
/// </summary>
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<IFocasClient> 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; }
/// <summary>
/// Cached per-axis decimal-place counts from <c>cnc_getfigure</c> (issue #262).
/// Populated once per session (the increment system rarely changes mid-run);
/// served by <see cref="FocasDriver.ApplyFigureScaling"/> when a future PR
/// surfaces position values that need scaling. Keys are axis names (or
/// fallback <c>"axis{n}"</c> until <c>cnc_rdaxisname</c> integration lands).
/// </summary>
public IReadOnlyDictionary<string, int>? 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();

View File

@@ -11,6 +11,34 @@ public sealed class FocasDriverOptions
public IReadOnlyList<FocasTagDefinition> Tags { get; init; } = [];
public FocasProbeOptions Probe { get; init; } = new();
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
/// <summary>
/// Fixed-tree behaviour knobs (issue #262, plan PR F1-f). Carries the
/// <c>ApplyFigureScaling</c> toggle that gates the <c>cnc_getfigure</c>
/// decimal-place division applied to position values before publishing.
/// </summary>
public FocasFixedTreeOptions FixedTree { get; init; } = new();
}
/// <summary>
/// Per-driver fixed-tree options. New installs default <see cref="ApplyFigureScaling"/>
/// to <c>true</c> so position values surface in user units (mm / inch). Existing
/// deployments that already published raw scaled integers can flip this to <c>false</c>
/// 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).
/// </summary>
public sealed record FocasFixedTreeOptions
{
/// <summary>
/// When <c>true</c> (default), position values from <c>cnc_absolute</c> /
/// <c>cnc_machine</c> / <c>cnc_relative</c> / <c>cnc_distance</c> /
/// <c>cnc_actf</c> are divided by <c>10^decimalPlaces</c> per axis using the
/// <c>cnc_getfigure</c> snapshot cached at probe time. When <c>false</c>, the
/// raw integer values are published unchanged — used for migrations from
/// older drivers that didn't apply the scaling.
/// </summary>
public bool ApplyFigureScaling { get; init; } = true;
}
/// <summary>

View File

@@ -318,6 +318,43 @@ internal sealed class FwlibFocasClient : IFocasClient
new FocasCurrentBlockInfo(TrimAnsiPadding(buf.Data)));
}
public Task<IReadOnlyDictionary<string, int>?> GetFigureScalingAsync(CancellationToken cancellationToken)
{
if (!_connected) return Task.FromResult<IReadOnlyDictionary<string, int>?>(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<IReadOnlyDictionary<string, int>?>(null);
return Task.FromResult<IReadOnlyDictionary<string, int>?>(DecodeFigureScaling(buf.Data, count));
}
/// <summary>
/// Decode the per-axis decimal-place counts from a <c>cnc_getfigure</c> reply
/// buffer. Each axis entry per <c>fwlib32.h</c> is 8 bytes laid out as
/// <c>short dec</c> + <c>short unit</c> + 4 reserved bytes; we read only
/// <c>dec</c>. Keys are 1-based <c>"axis{n}"</c> placeholders — a follow-up
/// PR can rewire to <c>cnc_rdaxisname</c> once that surface lands without
/// changing the cache contract (issue #262).
/// </summary>
internal static IReadOnlyDictionary<string, int> DecodeFigureScaling(byte[] data, short count)
{
var clamped = Math.Max((short)0, Math.Min(count, (short)FwlibNative.MAX_AXIS));
var result = new Dictionary<string, int>(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;
}
/// <summary>
/// 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

View File

@@ -157,6 +157,25 @@ internal static class FwlibNative
short length,
ref OPMSG3 buffer);
// ---- Figure (per-axis decimal scaling) ----
/// <summary>
/// <c>cnc_getfigure</c> — read per-axis figure info (decimal-place counts + units).
/// <paramref name="kind"/>: 0 = absolute / relative / machine position figures,
/// 1 = work-coord shift figures (per Fanuc reference). The reply struct holds
/// up to <see cref="MAX_AXIS"/> axis entries; the managed side reads the count
/// out via <paramref name="outCount"/>. Position values from <c>cnc_absolute</c>
/// / <c>cnc_machine</c> / <c>cnc_relative</c> / <c>cnc_distance</c> / <c>cnc_actf</c>
/// are scaled integers — divide by <c>10^figureinfo[axis].dec</c> for user units
/// (issue #262, plan PR F1-f).
/// </summary>
[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 ----
/// <summary>
@@ -298,6 +317,31 @@ internal static class FwlibNative
public byte[] Data;
}
/// <summary>
/// Maximum axis count per the FWLIB <c>fwlib32.h</c> ceiling for figure-info reads.
/// Real Fanuc CNCs cap at 8 simultaneous axes for most series; we marshal an
/// 8-entry array (matches <see cref="IODBAXIS"/>) so the call completes regardless
/// of the deployment's axis count (issue #262).
/// </summary>
public const int MAX_AXIS = 8;
/// <summary>
/// IODBAXIS — per-axis figure info read buffer for <c>cnc_getfigure</c>. Each
/// axis entry carries the decimal-place count (<c>dec</c>) the CNC reports for
/// that axis's increment system + a unit code. The managed side reads the first
/// <c>outCount</c> entries returned by FWLIB; we marshal a fixed 8-entry ceiling
/// (issue #262, plan PR F1-f).
/// </summary>
[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;
}
/// <summary>ODBST — CNC status info. Machine state, alarm flags, automatic / edit mode.</summary>
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct ODBST

View File

@@ -135,6 +135,19 @@ public interface IFocasClient : IDisposable
/// </summary>
Task<FocasCurrentBlockInfo?> GetCurrentBlockAsync(CancellationToken cancellationToken)
=> Task.FromResult<FocasCurrentBlockInfo?>(null);
/// <summary>
/// Read the per-axis decimal-place counts via <c>cnc_getfigure</c> (issue #262).
/// Returned dictionary maps axis name (or fallback <c>"axis{n}"</c> when
/// <c>cnc_rdaxisname</c> 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 <c>null</c> when the wire client cannot supply the snapshot (older
/// transport variant) — the driver leaves the cache untouched and falls back to
/// publishing raw values.
/// </summary>
Task<IReadOnlyDictionary<string, int>?> GetFigureScalingAsync(CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlyDictionary<string, int>?>(null);
}
/// <summary>

View File

@@ -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";
/// <summary>
/// Variant of <see cref="FakeFocasClient"/> that returns configurable
/// per-axis figure scaling for the F1-f cache + diagnostics surface
/// (issue #262).
/// </summary>
private sealed class FigureAwareFakeFocasClient : FakeFocasClient, IFocasClient
{
public IReadOnlyDictionary<string, int>? Scaling { get; set; }
Task<IReadOnlyDictionary<string, int>?> 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<string, int>(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<string, int> { ["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<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) { } }
}
}