Files
lmxopcua/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
2026-04-25 14:49:11 -04:00

1038 lines
49 KiB
C#

using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// <summary>
/// FOCAS driver for Fanuc CNC controllers (FS 0i / 16i / 18i / 21i / 30i / 31i / 32i / Series
/// 35i / Power Mate i). Talks to the CNC via the Fanuc FOCAS/2 FWLIB protocol through an
/// <see cref="IFocasClient"/> the deployment supplies — FWLIB itself is Fanuc-proprietary
/// and cannot be redistributed.
/// </summary>
/// <remarks>
/// PR 1 ships <see cref="IDriver"/> only; read / write / discover / subscribe / probe / host-
/// resolver capabilities land in PRs 2 and 3. The <see cref="IFocasClient"/> abstraction
/// shipped here lets PR 2 onward stay license-clean — all tests run against a fake client
/// + the default <see cref="UnimplementedFocasClientFactory"/> makes misconfigured servers
/// fail fast.
/// </remarks>
public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable
{
private readonly FocasDriverOptions _options;
private readonly string _driverInstanceId;
private readonly IFocasClientFactory _clientFactory;
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 readonly Dictionary<string, (string Host, string Field)> _productionNodesByName =
new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, (string Host, string Field)> _modalNodesByName =
new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, (string Host, string Field)> _overrideNodesByName =
new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, string> _toolingNodesByName =
new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, (string Host, string Slot, string Axis)> _offsetNodesByName =
new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, string> _messagesNodesByName =
new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, string> _currentBlockNodesByName =
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",
];
/// <summary>
/// Names of the 4 fixed-tree <c>Production/</c> child nodes per device — parts
/// produced/required/total via <c>cnc_rdparam(6711/6712/6713)</c> + cycle-time
/// seconds (issue #258). Order matters for deterministic discovery output.
/// </summary>
private static readonly string[] ProductionFieldNames =
[
"PartsProduced", "PartsRequired", "PartsTotal", "CycleTimeSeconds",
];
/// <summary>
/// Names of the active modal aux-code child nodes per device — M/S/T/B from
/// <c>cnc_modal(type=100..103)</c> (issue #259). G-group decoding is a deferred
/// follow-up because the FWLIB <c>ODBMDL</c> union varies per series + group.
/// </summary>
private static readonly string[] ModalFieldNames = ["MCode", "SCode", "TCode", "BCode"];
/// <summary>
/// Names of the four operator-override child nodes per device — Feed / Rapid /
/// Spindle / Jog from <c>cnc_rdparam</c> with MTB-specific parameter numbers
/// (issue #259). A device whose <c>FocasOverrideParameters</c> entry is null for a
/// given field has the matching node omitted from the address space.
/// </summary>
private static readonly string[] OverrideFieldNames = ["Feed", "Rapid", "Spindle", "Jog"];
/// <summary>
/// Names of the standard work-coordinate offset slots surfaced under
/// <c>Offsets/</c> per device — G54..G59 from <c>cnc_rdzofs(n=1..6)</c>
/// (issue #260). Extended G54.1 P1..P48 surfaces are deferred to a follow-up
/// PR because <c>cnc_rdzofsr</c> uses a different range surface.
/// </summary>
private static readonly string[] WorkOffsetSlotNames =
[
"G54", "G55", "G56", "G57", "G58", "G59",
];
/// <summary>
/// Axis columns surfaced under each <c>Offsets/{slot}/</c> folder. Per the F1-d
/// plan a fixed 3-axis (X/Y/Z) view is used; lathes / mills with extra rotational
/// offsets get those columns exposed as 0.0 until a follow-up extends the surface.
/// </summary>
private static readonly string[] WorkOffsetAxisNames = ["X", "Y", "Z"];
public event EventHandler<DataChangeEventArgs>? OnDataChange;
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
public FocasDriver(FocasDriverOptions options, string driverInstanceId,
IFocasClientFactory? clientFactory = null)
{
ArgumentNullException.ThrowIfNull(options);
_options = options;
_driverInstanceId = driverInstanceId;
_clientFactory = clientFactory ?? new FwlibFocasClientFactory();
_poll = new PollGroupEngine(
reader: ReadAsync,
onChange: (handle, tagRef, snapshot) =>
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)));
}
public string DriverInstanceId => _driverInstanceId;
public string DriverType => "FOCAS";
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
_health = new DriverHealth(DriverState.Initializing, null, null);
try
{
foreach (var device in _options.Devices)
{
var addr = FocasHostAddress.TryParse(device.HostAddress)
?? throw new InvalidOperationException(
$"FOCAS device has invalid HostAddress '{device.HostAddress}' — expected 'focas://{{ip}}[:{{port}}]'.");
_devices[device.HostAddress] = new DeviceState(addr, device);
}
// Pre-flight: validate every tag's address against the declared CNC
// series so misconfigured addresses fail at init (clear config error)
// instead of producing BadOutOfRange on every read at runtime.
// Series=Unknown short-circuits the matrix; pre-matrix configs stay permissive.
foreach (var tag in _options.Tags)
{
var parsed = FocasAddress.TryParse(tag.Address)
?? throw new InvalidOperationException(
$"FOCAS tag '{tag.Name}' has invalid Address '{tag.Address}'. " +
$"Expected forms: R100, R100.3, PARAM:1815/0, MACRO:500.");
if (_devices.TryGetValue(tag.DeviceHostAddress, out var device)
&& FocasCapabilityMatrix.Validate(device.Options.Series, parsed) is { } reason)
{
throw new InvalidOperationException(
$"FOCAS tag '{tag.Name}' ({tag.Address}) rejected by capability matrix: {reason}");
}
_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);
foreach (var field in ProductionFieldNames)
_productionNodesByName[ProductionReferenceFor(device.Options.HostAddress, field)] =
(device.Options.HostAddress, field);
foreach (var field in ModalFieldNames)
_modalNodesByName[ModalReferenceFor(device.Options.HostAddress, field)] =
(device.Options.HostAddress, field);
if (device.Options.OverrideParameters is { } op)
{
foreach (var field in OverrideFieldNames)
{
if (OverrideParamFor(op, field) is null) continue;
_overrideNodesByName[OverrideReferenceFor(device.Options.HostAddress, field)] =
(device.Options.HostAddress, field);
}
}
// Tooling/CurrentTool — single Int16 node per device (issue #260). Tool
// life + active offset index are deferred per the F1-d plan; they need
// ODBTLIFE* unions whose shape varies per series.
if (FocasCapabilityMatrix.SupportsTooling(device.Options.Series))
{
_toolingNodesByName[ToolingReferenceFor(device.Options.HostAddress, "CurrentTool")] =
device.Options.HostAddress;
}
// Offsets/{G54..G59}/{X|Y|Z} — fixed 3-axis view of the standard work-
// coordinate offsets (issue #260). Capability matrix gates by series so
// legacy CNCs that don't support cnc_rdzofs don't produce the subtree.
if (FocasCapabilityMatrix.SupportsWorkOffsets(device.Options.Series))
{
foreach (var slot in WorkOffsetSlotNames)
foreach (var axis in WorkOffsetAxisNames)
{
_offsetNodesByName[OffsetReferenceFor(device.Options.HostAddress, slot, axis)] =
(device.Options.HostAddress, slot, axis);
}
}
// Messages/External/Latest + Program/CurrentBlock — single String nodes per
// device backed by cnc_rdopmsg3 + cnc_rdactpt caches refreshed on the probe
// tick (issue #261). Permissive across series (no capability gate yet).
_messagesNodesByName[MessagesLatestReferenceFor(device.Options.HostAddress)] =
device.Options.HostAddress;
_currentBlockNodesByName[CurrentBlockReferenceFor(device.Options.HostAddress)] =
device.Options.HostAddress;
}
if (_options.Probe.Enabled)
{
foreach (var state in _devices.Values)
{
state.ProbeCts = new CancellationTokenSource();
var ct = state.ProbeCts.Token;
_ = Task.Run(() => ProbeLoopAsync(state, ct), ct);
}
}
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
}
catch (Exception ex)
{
_health = new DriverHealth(DriverState.Faulted, null, ex.Message);
throw;
}
return Task.CompletedTask;
}
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
}
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
await _poll.DisposeAsync().ConfigureAwait(false);
foreach (var state in _devices.Values)
{
try { state.ProbeCts?.Cancel(); } catch { }
state.ProbeCts?.Dispose();
state.ProbeCts = null;
state.DisposeClient();
}
_devices.Clear();
_tagsByName.Clear();
_statusNodesByName.Clear();
_productionNodesByName.Clear();
_modalNodesByName.Clear();
_overrideNodesByName.Clear();
_toolingNodesByName.Clear();
_offsetNodesByName.Clear();
_messagesNodesByName.Clear();
_currentBlockNodesByName.Clear();
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
}
public DriverHealth GetHealth() => _health;
public long GetMemoryFootprint() => 0;
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
internal int DeviceCount => _devices.Count;
internal DeviceState? GetDeviceState(string hostAddress) =>
_devices.TryGetValue(hostAddress, out var s) ? s : null;
// ---- IReadable ----
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(fullReferences);
var now = DateTime.UtcNow;
var results = new DataValueSnapshot[fullReferences.Count];
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;
}
// Fixed-tree Production/ nodes — served from the per-device cached production
// snapshot refreshed on the probe tick (issue #258). No wire call here.
if (_productionNodesByName.TryGetValue(reference, out var prodKey))
{
results[i] = ReadProductionField(prodKey.Host, prodKey.Field, now);
continue;
}
// Fixed-tree Modal/ + Override/ nodes — served from per-device cached snapshots
// refreshed on the probe tick (issue #259). Same cache-or-Bad policy as Status/.
if (_modalNodesByName.TryGetValue(reference, out var modalKey))
{
results[i] = ReadModalField(modalKey.Host, modalKey.Field, now);
continue;
}
if (_overrideNodesByName.TryGetValue(reference, out var overrideKey))
{
results[i] = ReadOverrideField(overrideKey.Host, overrideKey.Field, now);
continue;
}
// Fixed-tree Tooling/CurrentTool — served from cached cnc_rdtnum snapshot
// refreshed on the probe tick (issue #260). No wire call here.
if (_toolingNodesByName.TryGetValue(reference, out var toolingHost))
{
results[i] = ReadToolingField(toolingHost, "CurrentTool", now);
continue;
}
// Fixed-tree Offsets/{slot}/{axis} — served from cached cnc_rdzofs(1..6)
// snapshot refreshed on the probe tick (issue #260). No wire call here.
if (_offsetNodesByName.TryGetValue(reference, out var offsetKey))
{
results[i] = ReadOffsetField(offsetKey.Host, offsetKey.Slot, offsetKey.Axis, now);
continue;
}
// Fixed-tree Messages/External/Latest + Program/CurrentBlock — served from
// cnc_rdopmsg3 + cnc_rdactpt caches refreshed on the probe tick (issue #261).
if (_messagesNodesByName.TryGetValue(reference, out var messagesHost))
{
results[i] = ReadMessagesLatestField(messagesHost, now);
continue;
}
if (_currentBlockNodesByName.TryGetValue(reference, out var blockHost))
{
results[i] = ReadCurrentBlockField(blockHost, now);
continue;
}
if (!_tagsByName.TryGetValue(reference, out var def))
{
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
continue;
}
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
{
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
continue;
}
try
{
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
var parsed = FocasAddress.TryParse(def.Address)
?? throw new InvalidOperationException($"FOCAS tag '{def.Name}' has malformed Address '{def.Address}'.");
var (value, status) = await client.ReadAsync(parsed, def.DataType, cancellationToken).ConfigureAwait(false);
results[i] = new DataValueSnapshot(value, status, now, now);
if (status == FocasStatusMapper.Good)
_health = new DriverHealth(DriverState.Healthy, now, null);
else
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
$"FOCAS status 0x{status:X8} reading {reference}");
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
}
}
return results;
}
// ---- IWritable ----
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(writes);
var results = new WriteResult[writes.Count];
for (var i = 0; i < writes.Count; i++)
{
var w = writes[i];
if (!_tagsByName.TryGetValue(w.FullReference, out var def))
{
results[i] = new WriteResult(FocasStatusMapper.BadNodeIdUnknown);
continue;
}
if (!def.Writable)
{
results[i] = new WriteResult(FocasStatusMapper.BadNotWritable);
continue;
}
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
{
results[i] = new WriteResult(FocasStatusMapper.BadNodeIdUnknown);
continue;
}
try
{
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
var parsed = FocasAddress.TryParse(def.Address)
?? throw new InvalidOperationException($"FOCAS tag '{def.Name}' has malformed Address '{def.Address}'.");
var status = await client.WriteAsync(parsed, def.DataType, w.Value, cancellationToken).ConfigureAwait(false);
results[i] = new WriteResult(status);
}
catch (OperationCanceledException) { throw; }
catch (NotSupportedException nse)
{
results[i] = new WriteResult(FocasStatusMapper.BadNotSupported);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
}
catch (Exception ex) when (ex is FormatException or InvalidCastException)
{
results[i] = new WriteResult(FocasStatusMapper.BadTypeMismatch);
}
catch (OverflowException)
{
results[i] = new WriteResult(FocasStatusMapper.BadOutOfRange);
}
catch (Exception ex)
{
results[i] = new WriteResult(FocasStatusMapper.BadCommunicationError);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
}
}
return results;
}
// ---- ITagDiscovery ----
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(builder);
var root = builder.Folder("FOCAS", "FOCAS");
foreach (var device in _options.Devices)
{
var label = device.DeviceName ?? device.HostAddress;
var deviceFolder = root.Folder(device.HostAddress, label);
var tagsForDevice = _options.Tags.Where(t =>
string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
foreach (var tag in tagsForDevice)
{
deviceFolder.Variable(tag.Name, tag.Name, new DriverAttributeInfo(
FullName: tag.Name,
DriverDataType: tag.DataType.ToDriverDataType(),
IsArray: false,
ArrayDim: null,
SecurityClass: tag.Writable
? SecurityClassification.Operate
: SecurityClassification.ViewOnly,
IsHistorized: false,
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));
}
// Fixed-tree Production/ subfolder — 4 read-only Int32 nodes: parts produced /
// required / total + cycle-time seconds (issue #258). Cached on the probe tick
// + served from DeviceState.LastProduction.
var productionFolder = deviceFolder.Folder("Production", "Production");
foreach (var field in ProductionFieldNames)
{
var fullRef = ProductionReferenceFor(device.HostAddress, field);
productionFolder.Variable(field, field, new DriverAttributeInfo(
FullName: fullRef,
DriverDataType: DriverDataType.Int32,
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: false));
}
// Fixed-tree Modal/ subfolder — 4 read-only Int16 nodes for the universally-
// present aux modal codes M/S/T/B from cnc_modal(type=100..103). G-group
// surfaces are deferred to a follow-up because the FWLIB ODBMDL union varies
// per series + group (issue #259, plan PR F1-c).
var modalFolder = deviceFolder.Folder("Modal", "Modal");
foreach (var field in ModalFieldNames)
{
var fullRef = ModalReferenceFor(device.HostAddress, field);
modalFolder.Variable(field, field, new DriverAttributeInfo(
FullName: fullRef,
DriverDataType: DriverDataType.Int16,
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: false));
}
// Fixed-tree Override/ subfolder — Feed / Rapid / Spindle / Jog from
// cnc_rdparam at MTB-specific parameter numbers (issue #259). Suppressed when
// OverrideParameters is null; per-field nodes whose parameter is null are
// omitted so a deployment can hide overrides their MTB doesn't wire up.
if (device.OverrideParameters is { } overrideParams)
{
var overrideFolder = deviceFolder.Folder("Override", "Override");
foreach (var field in OverrideFieldNames)
{
if (OverrideParamFor(overrideParams, field) is null) continue;
var fullRef = OverrideReferenceFor(device.HostAddress, field);
overrideFolder.Variable(field, field, new DriverAttributeInfo(
FullName: fullRef,
DriverDataType: DriverDataType.Int16,
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: false));
}
}
// Fixed-tree Tooling/ subfolder — single Int16 CurrentTool node from
// cnc_rdtnum (issue #260). Tool life + active offset index are deferred
// per the F1-d plan because the FWLIB ODBTLIFE* unions vary per series.
if (FocasCapabilityMatrix.SupportsTooling(device.Series))
{
var toolingFolder = deviceFolder.Folder("Tooling", "Tooling");
var toolingRef = ToolingReferenceFor(device.HostAddress, "CurrentTool");
toolingFolder.Variable("CurrentTool", "CurrentTool", new DriverAttributeInfo(
FullName: toolingRef,
DriverDataType: DriverDataType.Int16,
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: false));
}
// Fixed-tree Offsets/ subfolder — G54..G59 each with X/Y/Z Float64 axes
// from cnc_rdzofs(n=1..6) (issue #260). Capability matrix gates the surface
// by series so legacy controllers without cnc_rdzofs support don't expose
// dead nodes. Extended G54.1 P1..P48 surfaces are deferred to a follow-up.
if (FocasCapabilityMatrix.SupportsWorkOffsets(device.Series))
{
var offsetsFolder = deviceFolder.Folder("Offsets", "Offsets");
foreach (var slot in WorkOffsetSlotNames)
{
var slotFolder = offsetsFolder.Folder(slot, slot);
foreach (var axis in WorkOffsetAxisNames)
{
var fullRef = OffsetReferenceFor(device.HostAddress, slot, axis);
slotFolder.Variable(axis, axis, new DriverAttributeInfo(
FullName: fullRef,
DriverDataType: DriverDataType.Float64,
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: false));
}
}
}
// Fixed-tree Messages/External/Latest — single String node per device backed
// by cnc_rdopmsg3 across the four FANUC operator-message classes (issue #261).
// The issue body permits this minimal "latest message" surface in the first
// cut over a full ring-buffer of all four slots.
var messagesFolder = deviceFolder.Folder("Messages", "Messages");
var externalFolder = messagesFolder.Folder("External", "External");
var messagesRef = MessagesLatestReferenceFor(device.HostAddress);
externalFolder.Variable("Latest", "Latest", new DriverAttributeInfo(
FullName: messagesRef,
DriverDataType: DriverDataType.String,
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: false));
// Fixed-tree Program/CurrentBlock — single String node per device backed by
// cnc_rdactpt (issue #261). Trim-stable round-trip per the issue body.
var programFolder = deviceFolder.Folder("Program", "Program");
var blockRef = CurrentBlockReferenceFor(device.HostAddress);
programFolder.Variable("CurrentBlock", "CurrentBlock", new DriverAttributeInfo(
FullName: blockRef,
DriverDataType: DriverDataType.String,
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 string ProductionReferenceFor(string hostAddress, string field) =>
$"{hostAddress}::Production/{field}";
private static string ModalReferenceFor(string hostAddress, string field) =>
$"{hostAddress}::Modal/{field}";
private static string OverrideReferenceFor(string hostAddress, string field) =>
$"{hostAddress}::Override/{field}";
private static string ToolingReferenceFor(string hostAddress, string field) =>
$"{hostAddress}::Tooling/{field}";
private static string OffsetReferenceFor(string hostAddress, string slot, string axis) =>
$"{hostAddress}::Offsets/{slot}/{axis}";
private static string MessagesLatestReferenceFor(string hostAddress) =>
$"{hostAddress}::Messages/External/Latest";
private static string CurrentBlockReferenceFor(string hostAddress) =>
$"{hostAddress}::Program/CurrentBlock";
private static ushort? OverrideParamFor(FocasOverrideParameters p, string field) => field switch
{
"Feed" => p.FeedParam,
"Rapid" => p.RapidParam,
"Spindle" => p.SpindleParam,
"Jog" => p.JogParam,
_ => null,
};
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,
};
private static int? PickProductionField(FocasProductionInfo p, string field) => field switch
{
"PartsProduced" => p.PartsProduced,
"PartsRequired" => p.PartsRequired,
"PartsTotal" => p.PartsTotal,
"CycleTimeSeconds" => p.CycleTimeSeconds,
_ => null,
};
private static short? PickModalField(FocasModalInfo m, string field) => field switch
{
"MCode" => m.MCode,
"SCode" => m.SCode,
"TCode" => m.TCode,
"BCode" => m.BCode,
_ => null,
};
private static short? PickOverrideField(FocasOverrideInfo o, string field) => field switch
{
"Feed" => o.Feed,
"Rapid" => o.Rapid,
"Spindle" => o.Spindle,
"Jog" => o.Jog,
_ => null,
};
// ---- ISubscribable (polling overlay via shared engine) ----
public Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) =>
Task.FromResult(_poll.Subscribe(fullReferences, publishingInterval));
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
{
_poll.Unsubscribe(handle);
return Task.CompletedTask;
}
// ---- IHostConnectivityProbe ----
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses() =>
[.. _devices.Values.Select(s => new HostConnectivityStatus(s.Options.HostAddress, s.HostState, s.HostStateChangedUtc))];
private async Task ProbeLoopAsync(DeviceState state, CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
var success = false;
try
{
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;
}
// Refresh the cached production snapshot too — same best-effort policy
// as Status/: a null result leaves the previous good snapshot in place
// so reads keep serving until the next successful refresh (issue #258).
var production = await client.GetProductionAsync(ct).ConfigureAwait(false);
if (production is not null)
{
state.LastProduction = production;
state.LastProductionUtc = DateTime.UtcNow;
}
// Modal aux M/S/T/B + per-device operator overrides — same best-effort
// policy as Status/ + Production/. Override snapshot is suppressed when
// the device has no OverrideParameters configured (issue #259).
var modal = await client.GetModalAsync(ct).ConfigureAwait(false);
if (modal is not null)
{
state.LastModal = modal;
state.LastModalUtc = DateTime.UtcNow;
}
if (state.Options.OverrideParameters is { } overrideParams)
{
var ov = await client.GetOverrideAsync(overrideParams, ct).ConfigureAwait(false);
if (ov is not null)
{
state.LastOverride = ov;
state.LastOverrideUtc = DateTime.UtcNow;
}
}
// Tooling/CurrentTool + Offsets/{G54..G59}/{X|Y|Z} — same best-
// effort policy as the other fixed-tree caches (issue #260). A
// null result leaves the previous good snapshot in place so reads
// keep serving until the next successful refresh.
if (FocasCapabilityMatrix.SupportsTooling(state.Options.Series))
{
var tooling = await client.GetToolingAsync(ct).ConfigureAwait(false);
if (tooling is not null)
{
state.LastTooling = tooling;
state.LastToolingUtc = DateTime.UtcNow;
}
}
if (FocasCapabilityMatrix.SupportsWorkOffsets(state.Options.Series))
{
var offsets = await client.GetWorkOffsetsAsync(ct).ConfigureAwait(false);
if (offsets is not null)
{
state.LastWorkOffsets = offsets;
state.LastWorkOffsetsUtc = DateTime.UtcNow;
}
}
// Operator messages + currently-executing block — same best-effort
// policy as the other fixed-tree caches (issue #261). A null result
// leaves the previous good snapshot in place so reads keep serving
// until the next successful refresh.
var messages = await client.GetOperatorMessagesAsync(ct).ConfigureAwait(false);
if (messages is not null)
{
state.LastMessages = messages;
state.LastMessagesUtc = DateTime.UtcNow;
}
var block = await client.GetCurrentBlockAsync(ct).ConfigureAwait(false);
if (block is not null)
{
state.LastCurrentBlock = block;
state.LastCurrentBlockUtc = DateTime.UtcNow;
}
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
catch { /* connect-failure path already disposed + cleared the client */ }
TransitionDeviceState(state, success ? HostState.Running : HostState.Stopped);
try { await Task.Delay(_options.Probe.Interval, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { break; }
}
}
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 DataValueSnapshot ReadProductionField(string hostAddress, string field, DateTime now)
{
if (!_devices.TryGetValue(hostAddress, out var device))
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
if (device.LastProduction is not { } snap)
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
var value = PickProductionField(snap, field);
if (value is null)
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
return new DataValueSnapshot((int)value, FocasStatusMapper.Good,
device.LastProductionUtc, now);
}
private DataValueSnapshot ReadModalField(string hostAddress, string field, DateTime now)
{
if (!_devices.TryGetValue(hostAddress, out var device))
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
if (device.LastModal is not { } snap)
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
var value = PickModalField(snap, field);
if (value is null)
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
return new DataValueSnapshot((short)value, FocasStatusMapper.Good,
device.LastModalUtc, now);
}
private DataValueSnapshot ReadOverrideField(string hostAddress, string field, DateTime now)
{
if (!_devices.TryGetValue(hostAddress, out var device))
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
if (device.LastOverride is not { } snap)
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
var value = PickOverrideField(snap, field);
if (value is null)
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
return new DataValueSnapshot((short)value, FocasStatusMapper.Good,
device.LastOverrideUtc, now);
}
private DataValueSnapshot ReadToolingField(string hostAddress, string field, DateTime now)
{
if (!_devices.TryGetValue(hostAddress, out var device))
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
if (device.LastTooling is not { } snap)
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
return field switch
{
"CurrentTool" => new DataValueSnapshot(snap.CurrentTool, FocasStatusMapper.Good,
device.LastToolingUtc, now),
_ => new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now),
};
}
private DataValueSnapshot ReadOffsetField(string hostAddress, string slot, string axis, DateTime now)
{
if (!_devices.TryGetValue(hostAddress, out var device))
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
if (device.LastWorkOffsets is not { } snap)
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
var match = snap.Offsets.FirstOrDefault(o =>
string.Equals(o.Name, slot, StringComparison.OrdinalIgnoreCase));
if (match is null)
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
var value = axis switch
{
"X" => (double?)match.X,
"Y" => match.Y,
"Z" => match.Z,
_ => null,
};
if (value is null)
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
return new DataValueSnapshot(value.Value, FocasStatusMapper.Good,
device.LastWorkOffsetsUtc, now);
}
private DataValueSnapshot ReadMessagesLatestField(string hostAddress, DateTime now)
{
if (!_devices.TryGetValue(hostAddress, out var device))
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
if (device.LastMessages is not { } snap)
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
// Snapshot is the trimmed list of active classes. "Latest" surfaces the last
// (most-recent) entry — the issue body permits this minimal "latest message"
// surface in lieu of a full ring buffer of all 4 classes.
var latest = snap.Messages.Count == 0
? string.Empty
: snap.Messages[snap.Messages.Count - 1].Text;
return new DataValueSnapshot(latest, FocasStatusMapper.Good,
device.LastMessagesUtc, now);
}
private DataValueSnapshot ReadCurrentBlockField(string hostAddress, DateTime now)
{
if (!_devices.TryGetValue(hostAddress, out var device))
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
if (device.LastCurrentBlock is not { } snap)
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
return new DataValueSnapshot(snap.Text, FocasStatusMapper.Good,
device.LastCurrentBlockUtc, now);
}
private void TransitionDeviceState(DeviceState state, HostState newState)
{
HostState old;
lock (state.ProbeLock)
{
old = state.HostState;
if (old == newState) return;
state.HostState = newState;
state.HostStateChangedUtc = DateTime.UtcNow;
}
OnHostStatusChanged?.Invoke(this,
new HostStatusChangedEventArgs(state.Options.HostAddress, old, newState));
}
// ---- IPerCallHostResolver ----
public string ResolveHost(string fullReference)
{
if (_tagsByName.TryGetValue(fullReference, out var def))
return def.DeviceHostAddress;
return _options.Devices.FirstOrDefault()?.HostAddress ?? DriverInstanceId;
}
private async Task<IFocasClient> EnsureConnectedAsync(DeviceState device, CancellationToken ct)
{
if (device.Client is { IsConnected: true } c) return c;
device.Client ??= _clientFactory.Create();
try
{
await device.Client.ConnectAsync(device.ParsedAddress, _options.Timeout, ct).ConfigureAwait(false);
}
catch
{
device.Client.Dispose();
device.Client = null;
throw;
}
return device.Client;
}
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
internal sealed class DeviceState(FocasHostAddress parsedAddress, FocasDeviceOptions options)
{
public FocasHostAddress ParsedAddress { get; } = parsedAddress;
public FocasDeviceOptions Options { get; } = options;
public IFocasClient? Client { get; set; }
public object ProbeLock { get; } = new();
public HostState HostState { get; set; } = HostState.Unknown;
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/&lt;field&gt;</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; }
/// <summary>
/// Cached <c>cnc_rdparam(6711/6712/6713)</c> + cycle-time snapshot, refreshed on
/// every probe tick. Reads of the per-device <c>Production/&lt;field&gt;</c>
/// fixed-tree nodes serve from this cache so they don't pile extra wire traffic
/// on top of the user-driven tag reads (issue #258).
/// </summary>
public FocasProductionInfo? LastProduction { get; set; }
public DateTime LastProductionUtc { get; set; }
/// <summary>
/// Cached <c>cnc_modal</c> M/S/T/B snapshot, refreshed on every probe tick.
/// Reads of the per-device <c>Modal/&lt;field&gt;</c> nodes serve from this cache
/// so they don't pile extra wire traffic on top of user-driven reads (issue #259).
/// </summary>
public FocasModalInfo? LastModal { get; set; }
public DateTime LastModalUtc { get; set; }
/// <summary>
/// Cached <c>cnc_rdparam</c> override snapshot, refreshed on every probe tick.
/// Suppressed when the device's <see cref="FocasDeviceOptions.OverrideParameters"/>
/// is null (no <c>Override/</c> nodes are exposed in that case — issue #259).
/// </summary>
public FocasOverrideInfo? LastOverride { get; set; }
public DateTime LastOverrideUtc { get; set; }
/// <summary>
/// Cached <c>cnc_rdtnum</c> snapshot — current tool number — refreshed on
/// every probe tick. Reads of <c>Tooling/CurrentTool</c> serve from this
/// cache so they don't pile extra wire traffic on top of user-driven
/// reads (issue #260).
/// </summary>
public FocasToolingInfo? LastTooling { get; set; }
public DateTime LastToolingUtc { get; set; }
/// <summary>
/// Cached <c>cnc_rdzofs(1..6)</c> snapshot — G54..G59 work-coordinate
/// offsets — refreshed on every probe tick. Reads of
/// <c>Offsets/{slot}/{X|Y|Z}</c> serve from this cache (issue #260).
/// </summary>
public FocasWorkOffsetsInfo? LastWorkOffsets { get; set; }
public DateTime LastWorkOffsetsUtc { get; set; }
/// <summary>
/// Cached <c>cnc_rdopmsg3</c> snapshot — active operator messages across
/// the four FANUC classes — refreshed on every probe tick. Reads of
/// <c>Messages/External/Latest</c> serve from this cache (issue #261).
/// </summary>
public FocasOperatorMessagesInfo? LastMessages { get; set; }
public DateTime LastMessagesUtc { get; set; }
/// <summary>
/// Cached <c>cnc_rdactpt</c> snapshot — currently-executing block text —
/// refreshed on every probe tick. Reads of <c>Program/CurrentBlock</c>
/// serve from this cache (issue #261).
/// </summary>
public FocasCurrentBlockInfo? LastCurrentBlock { get; set; }
public DateTime LastCurrentBlockUtc { get; set; }
public void DisposeClient()
{
Client?.Dispose();
Client = null;
}
}
}