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 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"]; 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; } 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> 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; } 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)); } 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 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; 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 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; } /// /// 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; } public void DisposeClient() { Client?.Dispose(); Client = null; } } }