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