using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS; /// /// 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 /// the deployment supplies — FWLIB itself is Fanuc-proprietary /// and cannot be redistributed. /// /// /// PR 1 ships only; read / write / discover / subscribe / probe / host- /// resolver capabilities land in PRs 2 and 3. The abstraction /// shipped here lets PR 2 onward stay license-clean — all tests run against a fake client /// + the default makes misconfigured servers /// fail fast. /// 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 _devices = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _tagsByName = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _statusNodesByName = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _productionNodesByName = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _modalNodesByName = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _overrideNodesByName = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _toolingNodesByName = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _offsetNodesByName = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _messagesNodesByName = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _currentBlockNodesByName = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _diagnosticsNodesByName = new(StringComparer.OrdinalIgnoreCase); private DriverHealth _health = new(DriverState.Unknown, null, null); /// /// Names of the 9 fixed-tree Status/ child nodes per device, mirroring the 9 /// fields of Fanuc's cnc_rdcncstat ODBST struct (issue #257). Order matters for /// deterministic discovery output. /// private static readonly string[] StatusFieldNames = [ "Tmmode", "Aut", "Run", "Motion", "Mstb", "EmergencyStop", "Alarm", "Edit", "Dummy", ]; /// /// Names of the 4 fixed-tree Production/ child nodes per device — parts /// produced/required/total via cnc_rdparam(6711/6712/6713) + cycle-time /// seconds (issue #258). Order matters for deterministic discovery output. /// private static readonly string[] ProductionFieldNames = [ "PartsProduced", "PartsRequired", "PartsTotal", "CycleTimeSeconds", ]; /// /// Names of the active modal aux-code child nodes per device — M/S/T/B from /// cnc_modal(type=100..103) (issue #259). G-group decoding is a deferred /// follow-up because the FWLIB ODBMDL union varies per series + group. /// private static readonly string[] ModalFieldNames = ["MCode", "SCode", "TCode", "BCode"]; /// /// Names of the four operator-override child nodes per device — Feed / Rapid / /// Spindle / Jog from cnc_rdparam with MTB-specific parameter numbers /// (issue #259). A device whose FocasOverrideParameters entry is null for a /// given field has the matching node omitted from the address space. /// private static readonly string[] OverrideFieldNames = ["Feed", "Rapid", "Spindle", "Jog"]; /// /// Names of the standard work-coordinate offset slots surfaced under /// Offsets/ per device — G54..G59 from cnc_rdzofs(n=1..6) /// (issue #260). Extended G54.1 P1..P48 surfaces are deferred to a follow-up /// PR because cnc_rdzofsr uses a different range surface. /// private static readonly string[] WorkOffsetSlotNames = [ "G54", "G55", "G56", "G57", "G58", "G59", ]; /// /// Axis columns surfaced under each Offsets/{slot}/ 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. /// private static readonly string[] WorkOffsetAxisNames = ["X", "Y", "Z"]; /// /// Names of the five fixed-tree Diagnostics/ child nodes per device — runtime /// counters surfaced for operator visibility (issue #262). Order matters for /// deterministic discovery output. /// /// ReadCount (Int64) — successful probe ticks since init /// ReadFailureCount (Int64) — failed probe ticks since init /// LastErrorMessage (String) — text of the last probe / read failure /// LastSuccessfulRead (DateTime) — UTC timestamp of the last good probe tick /// ReconnectCount (Int64) — wire reconnects observed since init /// /// private static readonly string[] DiagnosticsFieldNames = [ "ReadCount", "ReadFailureCount", "LastErrorMessage", "LastSuccessfulRead", "ReconnectCount", ]; public event EventHandler? OnDataChange; public event EventHandler? 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; // 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) { 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(); _diagnosticsNodesByName.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> ReadAsync( IReadOnlyList 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; } // 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); 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> WriteAsync( IReadOnlyList 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)); // 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}"; 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 string DiagnosticsReferenceFor(string hostAddress, string field) => $"{hostAddress}::Diagnostics/{field}"; 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 SubscribeAsync( IReadOnlyList 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 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; 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 // 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 (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); 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 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); } /// /// Apply cnc_getfigure-derived decimal scaling to a raw position value. /// Returns divided by 10^decimalPlaces when the /// device has a cached scaling entry for AND /// is on; otherwise /// returns the raw value as a double. Forward-looking — surfaced for /// future PRs that wire up Axes/{name}/AbsolutePosition etc. so they /// don't need to re-derive the policy (issue #262). /// 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; 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 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 { 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; } /// /// Cached cnc_rdcncstat snapshot, refreshed on every probe tick. Reads of /// the per-device Status/<field> fixed-tree nodes serve from this cache /// so they don't pile extra wire traffic on top of the user-driven tag reads. /// public FocasStatusInfo? LastStatus { get; set; } public DateTime LastStatusUtc { get; set; } /// /// Cached cnc_rdparam(6711/6712/6713) + cycle-time snapshot, refreshed on /// every probe tick. Reads of the per-device Production/<field> /// 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). /// public FocasProductionInfo? LastProduction { get; set; } public DateTime LastProductionUtc { get; set; } /// /// Cached cnc_modal M/S/T/B snapshot, refreshed on every probe tick. /// Reads of the per-device Modal/<field> nodes serve from this cache /// so they don't pile extra wire traffic on top of user-driven reads (issue #259). /// public FocasModalInfo? LastModal { get; set; } public DateTime LastModalUtc { get; set; } /// /// Cached cnc_rdparam override snapshot, refreshed on every probe tick. /// Suppressed when the device's /// is null (no Override/ nodes are exposed in that case — issue #259). /// public FocasOverrideInfo? LastOverride { get; set; } public DateTime LastOverrideUtc { get; set; } /// /// Cached cnc_rdtnum snapshot — current tool number — refreshed on /// every probe tick. Reads of Tooling/CurrentTool serve from this /// cache so they don't pile extra wire traffic on top of user-driven /// reads (issue #260). /// public FocasToolingInfo? LastTooling { get; set; } public DateTime LastToolingUtc { get; set; } /// /// Cached cnc_rdzofs(1..6) snapshot — G54..G59 work-coordinate /// offsets — refreshed on every probe tick. Reads of /// Offsets/{slot}/{X|Y|Z} serve from this cache (issue #260). /// public FocasWorkOffsetsInfo? LastWorkOffsets { get; set; } public DateTime LastWorkOffsetsUtc { get; set; } /// /// Cached cnc_rdopmsg3 snapshot — active operator messages across /// the four FANUC classes — refreshed on every probe tick. Reads of /// Messages/External/Latest serve from this cache (issue #261). /// public FocasOperatorMessagesInfo? LastMessages { get; set; } public DateTime LastMessagesUtc { get; set; } /// /// Cached cnc_rdactpt snapshot — currently-executing block text — /// refreshed on every probe tick. Reads of Program/CurrentBlock /// serve from this cache (issue #261). /// public FocasCurrentBlockInfo? LastCurrentBlock { get; set; } public DateTime LastCurrentBlockUtc { get; set; } /// /// Cached per-axis decimal-place counts from cnc_getfigure (issue #262). /// Populated once per session (the increment system rarely changes mid-run); /// served by when a future PR /// surfaces position values that need scaling. Keys are axis names (or /// fallback "axis{n}" until cnc_rdaxisname integration lands). /// public IReadOnlyDictionary? 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(); Client = null; } } }