@@ -24,8 +24,20 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
private readonly PollGroupEngine _poll;
|
||||
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, FocasTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, (string Host, string Field)> _statusNodesByName =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
|
||||
/// <summary>
|
||||
/// Names of the 9 fixed-tree <c>Status/</c> child nodes per device, mirroring the 9
|
||||
/// fields of Fanuc's <c>cnc_rdcncstat</c> ODBST struct (issue #257). Order matters for
|
||||
/// deterministic discovery output.
|
||||
/// </summary>
|
||||
private static readonly string[] StatusFieldNames =
|
||||
[
|
||||
"Tmmode", "Aut", "Run", "Motion", "Mstb", "EmergencyStop", "Alarm", "Edit", "Dummy",
|
||||
];
|
||||
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||
|
||||
@@ -76,6 +88,15 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
_tagsByName[tag.Name] = tag;
|
||||
}
|
||||
|
||||
// Per-device fixed-tree Status nodes — issue #257. Names are deterministic so
|
||||
// ReadAsync can dispatch on the synthetic full-reference without extra metadata.
|
||||
foreach (var device in _devices.Values)
|
||||
{
|
||||
foreach (var field in StatusFieldNames)
|
||||
_statusNodesByName[StatusReferenceFor(device.Options.HostAddress, field)] =
|
||||
(device.Options.HostAddress, field);
|
||||
}
|
||||
|
||||
if (_options.Probe.Enabled)
|
||||
{
|
||||
foreach (var state in _devices.Values)
|
||||
@@ -113,6 +134,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
}
|
||||
_devices.Clear();
|
||||
_tagsByName.Clear();
|
||||
_statusNodesByName.Clear();
|
||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||
}
|
||||
|
||||
@@ -136,6 +158,15 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
for (var i = 0; i < fullReferences.Count; i++)
|
||||
{
|
||||
var reference = fullReferences[i];
|
||||
|
||||
// Fixed-tree Status/ nodes — served from the per-device cached ODBST struct
|
||||
// refreshed on the probe tick (issue #257). No wire call here.
|
||||
if (_statusNodesByName.TryGetValue(reference, out var statusKey))
|
||||
{
|
||||
results[i] = ReadStatusField(statusKey.Host, statusKey.Field, now);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!_tagsByName.TryGetValue(reference, out var def))
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||
@@ -257,10 +288,44 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: tag.WriteIdempotent));
|
||||
}
|
||||
|
||||
// Fixed-tree Status/ subfolder — 9 read-only Int16 nodes mirroring the ODBST
|
||||
// fields (issue #257). Cached on the probe tick + served from DeviceState.LastStatus.
|
||||
var statusFolder = deviceFolder.Folder("Status", "Status");
|
||||
foreach (var field in StatusFieldNames)
|
||||
{
|
||||
var fullRef = StatusReferenceFor(device.HostAddress, field);
|
||||
statusFolder.Variable(field, field, new DriverAttributeInfo(
|
||||
FullName: fullRef,
|
||||
DriverDataType: DriverDataType.Int16,
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: false));
|
||||
}
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static string StatusReferenceFor(string hostAddress, string field) =>
|
||||
$"{hostAddress}::Status/{field}";
|
||||
|
||||
private static short? PickStatusField(FocasStatusInfo s, string field) => field switch
|
||||
{
|
||||
"Tmmode" => s.Tmmode,
|
||||
"Aut" => s.Aut,
|
||||
"Run" => s.Run,
|
||||
"Motion" => s.Motion,
|
||||
"Mstb" => s.Mstb,
|
||||
"EmergencyStop" => s.EmergencyStop,
|
||||
"Alarm" => s.Alarm,
|
||||
"Edit" => s.Edit,
|
||||
"Dummy" => s.Dummy,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
// ---- ISubscribable (polling overlay via shared engine) ----
|
||||
|
||||
public Task<ISubscriptionHandle> SubscribeAsync(
|
||||
@@ -287,6 +352,19 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
{
|
||||
var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
|
||||
success = await client.ProbeAsync(ct).ConfigureAwait(false);
|
||||
if (success)
|
||||
{
|
||||
// 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
|
||||
// unchanged so the previous good snapshot keeps serving until refreshed.
|
||||
var snapshot = await client.GetStatusAsync(ct).ConfigureAwait(false);
|
||||
if (snapshot is not null)
|
||||
{
|
||||
state.LastStatus = snapshot;
|
||||
state.LastStatusUtc = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
|
||||
catch { /* connect-failure path already disposed + cleared the client */ }
|
||||
@@ -298,6 +376,19 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
}
|
||||
}
|
||||
|
||||
private DataValueSnapshot ReadStatusField(string hostAddress, string field, DateTime now)
|
||||
{
|
||||
if (!_devices.TryGetValue(hostAddress, out var device))
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||
if (device.LastStatus is not { } snap)
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
|
||||
var value = PickStatusField(snap, field);
|
||||
if (value is null)
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||
return new DataValueSnapshot((short)value, FocasStatusMapper.Good,
|
||||
device.LastStatusUtc, now);
|
||||
}
|
||||
|
||||
private void TransitionDeviceState(DeviceState state, HostState newState)
|
||||
{
|
||||
HostState old;
|
||||
@@ -352,6 +443,14 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
public DateTime HostStateChangedUtc { get; set; } = DateTime.UtcNow;
|
||||
public CancellationTokenSource? ProbeCts { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Cached <c>cnc_rdcncstat</c> snapshot, refreshed on every probe tick. Reads of
|
||||
/// the per-device <c>Status/<field></c> fixed-tree nodes serve from this cache
|
||||
/// so they don't pile extra wire traffic on top of the user-driven tag reads.
|
||||
/// </summary>
|
||||
public FocasStatusInfo? LastStatus { get; set; }
|
||||
public DateTime LastStatusUtc { get; set; }
|
||||
|
||||
public void DisposeClient()
|
||||
{
|
||||
Client?.Dispose();
|
||||
|
||||
@@ -137,6 +137,24 @@ internal sealed class FwlibFocasClient : IFocasClient
|
||||
return Task.FromResult(ret == 0);
|
||||
}
|
||||
|
||||
public Task<FocasStatusInfo?> GetStatusAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.FromResult<FocasStatusInfo?>(null);
|
||||
var buf = new FwlibNative.ODBST();
|
||||
var ret = FwlibNative.StatInfo(_handle, ref buf);
|
||||
if (ret != 0) return Task.FromResult<FocasStatusInfo?>(null);
|
||||
return Task.FromResult<FocasStatusInfo?>(new FocasStatusInfo(
|
||||
Dummy: buf.Dummy,
|
||||
Tmmode: buf.TmMode,
|
||||
Aut: buf.Aut,
|
||||
Run: buf.Run,
|
||||
Motion: buf.Motion,
|
||||
Mstb: buf.Mstb,
|
||||
EmergencyStop: buf.Emergency,
|
||||
Alarm: buf.Alarm,
|
||||
Edit: buf.Edit));
|
||||
}
|
||||
|
||||
// ---- PMC ----
|
||||
|
||||
private (object? value, uint status) ReadPmc(FocasAddress address, FocasDataType type)
|
||||
|
||||
@@ -48,8 +48,37 @@ public interface IFocasClient : IDisposable
|
||||
/// responds with any valid status.
|
||||
/// </summary>
|
||||
Task<bool> ProbeAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Read the full <c>cnc_rdcncstat</c> ODBST struct (9 small-int status flags). The
|
||||
/// boolean <see cref="ProbeAsync"/> is preserved for cheap reachability checks; this
|
||||
/// method exposes the per-field detail used by the FOCAS driver's <c>Status/</c>
|
||||
/// fixed-tree nodes (see issue #257). Returns <c>null</c> if the wire client cannot
|
||||
/// supply the struct (e.g. transport/IPC variant where the contract has not been
|
||||
/// extended yet) — callers fall back to surfacing Bad on the per-field nodes.
|
||||
/// </summary>
|
||||
Task<FocasStatusInfo?> GetStatusAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult<FocasStatusInfo?>(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of the 9 fields returned by Fanuc's <c>cnc_rdcncstat</c> (ODBST). All fields
|
||||
/// are <c>short</c> per the FWLIB header — small enums whose meaning is documented in the
|
||||
/// Fanuc FOCAS reference (e.g. <c>emergency</c>: 0=released, 1=stop, 2=reset). Surfaced as
|
||||
/// <c>Int16</c> in the OPC UA address space rather than mapped enums so operators see
|
||||
/// exactly what the CNC reported.
|
||||
/// </summary>
|
||||
public sealed record FocasStatusInfo(
|
||||
short Dummy,
|
||||
short Tmmode,
|
||||
short Aut,
|
||||
short Run,
|
||||
short Motion,
|
||||
short Mstb,
|
||||
short EmergencyStop,
|
||||
short Alarm,
|
||||
short Edit);
|
||||
|
||||
/// <summary>Factory for <see cref="IFocasClient"/>s. One client per configured device.</summary>
|
||||
public interface IFocasClientFactory
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user