@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) { } }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user