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, IAlarmSource, 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 FocasAlarmProjection? _alarmProjection; private DriverHealth _health = new(DriverState.Unknown, null, null); public event EventHandler? OnDataChange; public event EventHandler? OnHostStatusChanged; public event EventHandler? OnAlarmEvent; public FocasDriver(FocasDriverOptions options, string driverInstanceId, IFocasClientFactory? clientFactory = null) { ArgumentNullException.ThrowIfNull(options); _options = options; _driverInstanceId = driverInstanceId; _clientFactory = clientFactory ?? new Wire.WireFocasClientFactory(); _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; } 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); } } if (_options.HandleRecycle.Enabled) { foreach (var state in _devices.Values) { state.RecycleCts = new CancellationTokenSource(); var ct = state.RecycleCts.Token; _ = Task.Run(() => RecycleLoopAsync(state, ct), ct); } } if (_options.AlarmProjection.Enabled) _alarmProjection = new FocasAlarmProjection(this, _options.AlarmProjection.PollInterval); if (_options.FixedTree.Enabled) { foreach (var state in _devices.Values) { state.FixedTreeCts = new CancellationTokenSource(); var ct = state.FixedTreeCts.Token; _ = Task.Run(() => FixedTreeLoopAsync(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); if (_alarmProjection is { } proj) { await proj.DisposeAsync().ConfigureAwait(false); _alarmProjection = null; } foreach (var state in _devices.Values) { try { state.ProbeCts?.Cancel(); } catch { } state.ProbeCts?.Dispose(); state.ProbeCts = null; try { state.RecycleCts?.Cancel(); } catch { } state.RecycleCts?.Dispose(); state.RecycleCts = null; try { state.FixedTreeCts?.Cancel(); } catch { } state.FixedTreeCts?.Dispose(); state.FixedTreeCts = null; state.DisposeClient(); } _devices.Clear(); _tagsByName.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 T1 — fixed-tree references are synthesized from the cached // dynamic snapshot + sysinfo; no P/Invoke per Read since the poll loop // already fires them on cadence. if (_options.FixedTree.Enabled && TryReadFixedTree(reference, now) is { } fx) { results[i] = fx; 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); // Fixed-tree T1 — Identity + Axes subtrees, populated once per session // from cnc_sysinfo + cnc_rdaxisname at init time and kept in DeviceState. if (_options.FixedTree.Enabled && _devices.TryGetValue(device.HostAddress, out var state) && state.FixedTreeCache is { } cache) { var identity = deviceFolder.Folder("Identity", "Identity"); EmitIdentityVariable(identity, device.HostAddress, "SeriesNumber", FocasDriverDataType.String); EmitIdentityVariable(identity, device.HostAddress, "Version", FocasDriverDataType.String); EmitIdentityVariable(identity, device.HostAddress, "MaxAxes", FocasDriverDataType.Int32); EmitIdentityVariable(identity, device.HostAddress, "CncType", FocasDriverDataType.String); EmitIdentityVariable(identity, device.HostAddress, "MtType", FocasDriverDataType.String); EmitIdentityVariable(identity, device.HostAddress, "AxisCount", FocasDriverDataType.Int32); var axesFolder = deviceFolder.Folder("Axes", "Axes"); foreach (var axis in cache.Axes) { var axisFolder = axesFolder.Folder(axis.Display, axis.Display); EmitAxisVariable(axisFolder, device.HostAddress, axis.Display, "AbsolutePosition"); EmitAxisVariable(axisFolder, device.HostAddress, axis.Display, "MachinePosition"); EmitAxisVariable(axisFolder, device.HostAddress, axis.Display, "RelativePosition"); EmitAxisVariable(axisFolder, device.HostAddress, axis.Display, "DistanceToGo"); if (cache.Capabilities.ServoLoad) EmitAxisVariable(axisFolder, device.HostAddress, axis.Display, "ServoLoad"); } EmitAxisVariable(axesFolder, device.HostAddress, "FeedRate", "Actual"); EmitAxisVariable(axesFolder, device.HostAddress, "SpindleSpeed", "Actual"); // Spindle subtree — one folder per discovered spindle, suppressed // entirely on series that don't export cnc_rdspdlname. Per-spindle // Load + MaxRpm each gated on their own capability probe. if (cache.Capabilities.Spindles) { var spindleRoot = deviceFolder.Folder("Spindle", "Spindle"); for (var i = 0; i < cache.Spindles.Count; i++) { var s = cache.Spindles[i]; var name = string.IsNullOrEmpty(s.Display) ? $"S{i + 1}" : s.Display; var spindleFolder = spindleRoot.Folder(name, name); if (cache.Capabilities.SpindleLoad) EmitFixedVariable(spindleFolder, device.HostAddress, $"Spindle/{name}", "Load", DriverDataType.Int32); if (cache.Capabilities.SpindleMaxRpm && i < cache.SpindleMaxRpms.Count) EmitFixedVariable(spindleFolder, device.HostAddress, $"Spindle/{name}", "MaxRpm", DriverDataType.Int32); } } // Fixed-tree T2 — Program + OperationMode subtrees (gated on capability). if (cache.Capabilities.ProgramInfo) { var program = deviceFolder.Folder("Program", "Program"); EmitFixedVariable(program, device.HostAddress, "Program", "Name", DriverDataType.String); EmitFixedVariable(program, device.HostAddress, "Program", "ONumber", DriverDataType.Int32); EmitFixedVariable(program, device.HostAddress, "Program", "Number", DriverDataType.Int32); EmitFixedVariable(program, device.HostAddress, "Program", "MainNumber", DriverDataType.Int32); EmitFixedVariable(program, device.HostAddress, "Program", "Sequence", DriverDataType.Int32); EmitFixedVariable(program, device.HostAddress, "Program", "BlockCount", DriverDataType.Int32); var opMode = deviceFolder.Folder("OperationMode", "OperationMode"); EmitFixedVariable(opMode, device.HostAddress, "OperationMode", "Mode", DriverDataType.Int32); EmitFixedVariable(opMode, device.HostAddress, "OperationMode", "ModeText", DriverDataType.String); } // Fixed-tree T3 — Timers subtree (power-on / operating / cutting / cycle). if (cache.Capabilities.Timers) { var timers = deviceFolder.Folder("Timers", "Timers"); EmitFixedVariable(timers, device.HostAddress, "Timers", "PowerOnSeconds", DriverDataType.Float64); EmitFixedVariable(timers, device.HostAddress, "Timers", "OperatingSeconds", DriverDataType.Float64); EmitFixedVariable(timers, device.HostAddress, "Timers", "CuttingSeconds", DriverDataType.Float64); EmitFixedVariable(timers, device.HostAddress, "Timers", "CycleSeconds", DriverDataType.Float64); } } 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)); } } return Task.CompletedTask; } private enum FocasDriverDataType { String, Int32, Float64 } private static void EmitIdentityVariable( IAddressSpaceBuilder folder, string deviceHost, string field, FocasDriverDataType type) { var fullName = FixedTreeReference(deviceHost, $"Identity/{field}"); folder.Variable(field, field, new DriverAttributeInfo( FullName: fullName, DriverDataType: type switch { FocasDriverDataType.Int32 => DriverDataType.Int32, FocasDriverDataType.Float64 => DriverDataType.Float64, _ => DriverDataType.String, }, IsArray: false, ArrayDim: null, SecurityClass: SecurityClassification.ViewOnly, IsHistorized: false, IsAlarm: false, WriteIdempotent: false)); } private static void EmitAxisVariable( IAddressSpaceBuilder folder, string deviceHost, string axisName, string field) { var fullName = FixedTreeReference(deviceHost, $"Axes/{axisName}/{field}"); folder.Variable(field, field, new DriverAttributeInfo( FullName: fullName, DriverDataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, SecurityClass: SecurityClassification.ViewOnly, IsHistorized: false, IsAlarm: false, WriteIdempotent: false)); } /// /// Emit a variable under a named fixed-tree folder (Program, OperationMode, /// …). Full-reference shape is {deviceHost}/{folderPath}/{field}. /// private static void EmitFixedVariable( IAddressSpaceBuilder folder, string deviceHost, string folderPath, string field, DriverDataType type) { var fullName = FixedTreeReference(deviceHost, $"{folderPath}/{field}"); folder.Variable(field, field, new DriverAttributeInfo( FullName: fullName, DriverDataType: type, IsArray: false, ArrayDim: null, SecurityClass: SecurityClassification.ViewOnly, IsHistorized: false, IsAlarm: false, WriteIdempotent: false)); } /// /// Canonical full-reference shape for a fixed-tree node. Keeps the device /// host as a prefix so multi-device configs don't collide, and the rest is /// the path inside the tree. Matches what poll-loop snapshots publish + /// what looks up. /// internal static string FixedTreeReference(string deviceHost, string path) => $"{deviceHost}/{path}"; // ---- 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); } 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; } } } /// /// Per-device fixed-tree poll loop. First tick resolves sysinfo + axis names /// (once) so can render the subtree on its next /// invocation; every tick thereafter fires a cnc_rddynamic2 per axis /// and publishes OnDataChange for the axis positions + feed rate + spindle /// speed. /// private async Task FixedTreeLoopAsync(DeviceState state, CancellationToken ct) { // Bootstrap: identity + axis names + per-optional-API capability probe. // Each optional call is attempted once; failures (EW_FUNC / EW_NOOPT / EW_VERSION) // record the capability as unsupported and suppress the corresponding nodes // in DiscoverAsync + the poll loop. while (!ct.IsCancellationRequested && state.FixedTreeCache is null) { try { var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false); var sys = await client.GetSysInfoAsync(ct).ConfigureAwait(false); var axes = await client.GetAxisNamesAsync(ct).ConfigureAwait(false); // Optional-API probes — each returns empty / throws when unsupported. var spindles = await SafeProbe(() => client.GetSpindleNamesAsync(ct), []); var spindleMaxRpms = await SafeProbe(() => client.GetSpindleMaxRpmsAsync(ct), []); var servoLoads = await SafeProbe(() => client.GetServoLoadsAsync(ct), []); var programInfo = await SafeTryProbe(() => client.GetProgramInfoAsync(ct)); var timer = await SafeTryProbe(() => client.GetTimerAsync(FocasTimerKind.PowerOn, ct)); var spindleLoad = await SafeProbe(() => client.GetSpindleLoadsAsync(ct), []); var caps = new FocasFixedTreeCapabilities( Spindles: spindles.Count > 0, SpindleLoad: spindleLoad.Count > 0, SpindleMaxRpm: spindleMaxRpms.Count > 0, ServoLoad: servoLoads.Count > 0, ProgramInfo: programInfo is not null, Timers: timer is not null); state.FixedTreeCache = new FocasFixedTreeCache( sys, [.. axes], [.. spindles], [.. spindleMaxRpms], caps); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; } catch { try { await Task.Delay(TimeSpan.FromSeconds(2), ct).ConfigureAwait(false); } catch (OperationCanceledException) { return; } } } // Prime the spindle-loads cache from bootstrap if supported — avoids a // "tree is there but reads say BadNodeIdUnknown" window on startup. if (state.FixedTreeCache?.Capabilities is { SpindleLoad: true }) { try { var client2 = await EnsureConnectedAsync(state, ct).ConfigureAwait(false); var loads = await client2.GetSpindleLoadsAsync(ct).ConfigureAwait(false); for (var i = 0; i < loads.Count; i++) state.LastSpindleLoads[i] = loads[i]; } catch { /* first-tick poll will retry */ } } var programPollDue = DateTime.MinValue; var timerPollDue = DateTime.MinValue; while (!ct.IsCancellationRequested) { try { var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false); var cache = state.FixedTreeCache; if (cache is null) break; FocasDynamicSnapshot? firstAxisSnap = null; for (var i = 0; i < cache.Axes.Count; i++) { var axisIndex = i + 1; // FOCAS uses 1-based axis indexing var axis = cache.Axes[i]; var snap = await client.ReadDynamicAsync(axisIndex, ct).ConfigureAwait(false); PublishAxisSnapshot(state, axis, snap); if (i == 0) { firstAxisSnap = snap; PublishRateSnapshot(state, snap); } } // Servo loads + spindle loads — both return bulk arrays, so folding // into the axis cadence is cheap. Each is gated by the bootstrap // capability probe — unsupported on this series = silent skip. if (cache.Capabilities.ServoLoad) { try { var loads = await client.GetServoLoadsAsync(ct).ConfigureAwait(false); PublishServoLoads(state, loads); } catch { /* transient — next tick retries */ } } if (cache.Capabilities.SpindleLoad) { try { var loads = await client.GetSpindleLoadsAsync(ct).ConfigureAwait(false); for (var i = 0; i < loads.Count; i++) state.LastSpindleLoads[i] = loads[i]; } catch { /* transient */ } } // Program-info poll runs on its own cadence — much slower than the axis // poll because program / mode transitions are operator-driven. var programInterval = _options.FixedTree.ProgramPollInterval; if (cache.Capabilities.ProgramInfo && programInterval > TimeSpan.Zero && DateTime.UtcNow >= programPollDue) { try { var program = await client.GetProgramInfoAsync(ct).ConfigureAwait(false); state.LastProgramInfo = program; if (firstAxisSnap is { } s) state.LastProgramAxisRef = s; } catch { /* transient — next tick retries */ } programPollDue = DateTime.UtcNow + programInterval; } // Timers — slowest cadence. Fires 4 FWLIB calls per tick (one per kind). var timerInterval = _options.FixedTree.TimerPollInterval; if (cache.Capabilities.Timers && timerInterval > TimeSpan.Zero && DateTime.UtcNow >= timerPollDue) { foreach (FocasTimerKind kind in Enum.GetValues()) { try { var t = await client.GetTimerAsync(kind, ct).ConfigureAwait(false); state.LastTimers[kind] = t; } catch { /* per-kind failures are non-fatal */ } } timerPollDue = DateTime.UtcNow + timerInterval; } } catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; } catch { /* next tick retries — transient blips are expected */ } try { await Task.Delay(_options.FixedTree.PollInterval, ct).ConfigureAwait(false); } catch (OperationCanceledException) { break; } } } /// /// Cache a fresh axis snapshot. The poll loop doesn't fire OnDataChange /// directly — subscribers go through the normal SubscribeAsync → /// path, which hits /// and returns these cached values. /// private static void PublishAxisSnapshot(DeviceState state, FocasAxisName axis, FocasDynamicSnapshot snap) { var host = state.Options.HostAddress; state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axis.Display}/AbsolutePosition")] = snap.AbsolutePosition; state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axis.Display}/MachinePosition")] = snap.MachinePosition; state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axis.Display}/RelativePosition")] = snap.RelativePosition; state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axis.Display}/DistanceToGo")] = snap.DistanceToGo; } private static void PublishRateSnapshot(DeviceState state, FocasDynamicSnapshot snap) { var host = state.Options.HostAddress; state.LastFixedSnapshots[FixedTreeReference(host, "Axes/FeedRate/Actual")] = snap.ActualFeedRate; state.LastFixedSnapshots[FixedTreeReference(host, "Axes/SpindleSpeed/Actual")] = snap.ActualSpindleSpeed; } /// /// Cache servo-load percentages keyed by axis name. Stored separately from /// LastFixedSnapshots (which is int-typed) so the double-valued load /// values don't need casting on every read. /// private static void PublishServoLoads(DeviceState state, IReadOnlyList loads) { foreach (var load in loads) state.LastServoLoads[load.AxisName] = load.LoadPercent; } private static object? TimerValue(DeviceState state, FocasTimerKind kind) => state.LastTimers.TryGetValue(kind, out var t) ? (object)t.TotalSeconds : null; /// /// Call an optional probe that returns a collection; swallow any exception /// and return . Used by bootstrap to capture /// per-series capability without letting one failed probe take down the /// entire bootstrap sequence. /// private static async Task> SafeProbe( Func>> probe, IReadOnlyList fallback) { try { return await probe().ConfigureAwait(false); } catch { return fallback; } } /// /// Nullable variant — probe returns a single object or null on failure. /// private static async Task SafeTryProbe(Func> probe) where T : class { try { return await probe().ConfigureAwait(false); } catch { return null; } } /// /// Read cached last-fixed-tree snapshots. Returns the projected value when /// the reference looks like a fixed-tree FullName; null when it doesn't /// (callers fall through to the user-authored tag path). /// private DataValueSnapshot? TryReadFixedTree(string reference, DateTime now) { foreach (var state in _devices.Values) { if (!reference.StartsWith(state.Options.HostAddress + "/", StringComparison.OrdinalIgnoreCase)) continue; if (state.LastFixedSnapshots.TryGetValue(reference, out var raw)) return new DataValueSnapshot((double)raw, FocasStatusMapper.Good, now, now); // Servo-load match: reference shape is "{host}/Axes/{name}/ServoLoad" var suffixFull = reference[(state.Options.HostAddress.Length + 1)..]; if (suffixFull.StartsWith("Axes/", StringComparison.Ordinal) && suffixFull.EndsWith("/ServoLoad", StringComparison.Ordinal)) { var axisName = suffixFull["Axes/".Length..^"/ServoLoad".Length]; if (state.LastServoLoads.TryGetValue(axisName, out var load)) return new DataValueSnapshot(load, FocasStatusMapper.Good, now, now); } // Spindle matches: "{host}/Spindle/{name}/Load" + "{host}/Spindle/{name}/MaxRpm" if (suffixFull.StartsWith("Spindle/", StringComparison.Ordinal) && state.FixedTreeCache is { } spindleCache) { var tail = suffixFull["Spindle/".Length..]; var slash = tail.IndexOf('/'); if (slash > 0) { var spindleName = tail[..slash]; var field = tail[(slash + 1)..]; var idx = -1; for (var i = 0; i < spindleCache.Spindles.Count; i++) { var s = spindleCache.Spindles[i]; var display = string.IsNullOrEmpty(s.Display) ? $"S{i + 1}" : s.Display; if (string.Equals(display, spindleName, StringComparison.OrdinalIgnoreCase)) { idx = i; break; } } if (idx >= 0) { object? value = field switch { "Load" => state.LastSpindleLoads.TryGetValue(idx, out var l) ? (object)l : null, "MaxRpm" => idx < spindleCache.SpindleMaxRpms.Count ? (object)spindleCache.SpindleMaxRpms[idx] : null, _ => null, }; if (value is not null) return new DataValueSnapshot(value, FocasStatusMapper.Good, now, now); } } } // Identity strings + program / op-mode fields aren't cached as doubles — // re-derive from the struct caches. if (state.FixedTreeCache is { } cache) { var suffix = reference[(state.Options.HostAddress.Length + 1)..]; var value = suffix switch { "Identity/SeriesNumber" => (object)cache.SysInfo.Series, "Identity/Version" => cache.SysInfo.Version, "Identity/MaxAxes" => cache.SysInfo.MaxAxis, "Identity/CncType" => cache.SysInfo.CncType, "Identity/MtType" => cache.SysInfo.MtType, "Identity/AxisCount" => cache.SysInfo.AxesCount, "Program/Name" => (object?)state.LastProgramInfo?.Name, "Program/ONumber" => state.LastProgramInfo?.ONumber, "Program/BlockCount" => state.LastProgramInfo?.BlockCount, "Program/Number" => state.LastProgramAxisRef?.ProgramNumber, "Program/MainNumber" => state.LastProgramAxisRef?.MainProgramNumber, "Program/Sequence" => state.LastProgramAxisRef?.SequenceNumber, "OperationMode/Mode" => state.LastProgramInfo?.Mode, "OperationMode/ModeText" => state.LastProgramInfo is { } pi ? FocasOpMode.ToText(pi.Mode) : null, "Timers/PowerOnSeconds" => TimerValue(state, FocasTimerKind.PowerOn), "Timers/OperatingSeconds" => TimerValue(state, FocasTimerKind.Operating), "Timers/CuttingSeconds" => TimerValue(state, FocasTimerKind.Cutting), "Timers/CycleSeconds" => TimerValue(state, FocasTimerKind.Cycle), _ => null, }; if (value is not null) return new DataValueSnapshot(value, FocasStatusMapper.Good, now, now); } } return null; } private async Task RecycleLoopAsync(DeviceState state, CancellationToken ct) { while (!ct.IsCancellationRequested) { try { await Task.Delay(_options.HandleRecycle.Interval, ct).ConfigureAwait(false); } catch (OperationCanceledException) { break; } // Close the current handle — the next Read / Write / Probe call triggers // EnsureConnectedAsync, which reopens a fresh one. We don't block here on // reconnect because the goal is just to release the FWLIB handle slot; a // readable tick one probe cycle later is an acceptable cost. try { state.DisposeClient(); } catch { /* already disposed or race — next EnsureConnected recovers */ } } } 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)); } // ---- IAlarmSource ---- public Task SubscribeAlarmsAsync( IReadOnlyList sourceNodeIds, CancellationToken cancellationToken) { if (_alarmProjection is null) throw new NotSupportedException( "FOCAS alarm projection is disabled — set FocasDriverOptions.AlarmProjection.Enabled=true to opt in."); return _alarmProjection.SubscribeAsync(sourceNodeIds, cancellationToken); } public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) => _alarmProjection is { } p ? p.UnsubscribeAsync(handle, cancellationToken) : Task.CompletedTask; public Task AcknowledgeAsync( IReadOnlyList acknowledgements, CancellationToken cancellationToken) => _alarmProjection is { } p ? p.AcknowledgeAsync(acknowledgements, cancellationToken) : Task.CompletedTask; internal void InvokeAlarmEvent(AlarmEventArgs args) => OnAlarmEvent?.Invoke(this, args); /// /// Poll every configured device's active-alarm list in one pass. Used by the alarm /// projection — kept internal rather than public because callers that /// want alarm events should subscribe through IAlarmSource instead. /// internal async Task Alarms)>> ReadActiveAlarmsAcrossDevicesAsync(HashSet? deviceFilter, CancellationToken ct) { var result = new List<(string, IReadOnlyList)>(); foreach (var state in _devices.Values) { if (deviceFilter is not null && !deviceFilter.Contains(state.Options.HostAddress)) continue; try { var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false); var alarms = await client.ReadAlarmsAsync(ct).ConfigureAwait(false); result.Add((state.Options.HostAddress, alarms)); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; } catch { /* surface a device-local fault on the next tick */ } } return result; } // ---- 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); /// /// Per-device fixed-tree cache populated once at first successful connect and /// read-only thereafter. Used by to render the /// tree + by for synchronous Identity/* reads. /// internal sealed record FocasFixedTreeCache( FocasSysInfo SysInfo, IReadOnlyList Axes, IReadOnlyList Spindles, IReadOnlyList SpindleMaxRpms, FocasFixedTreeCapabilities Capabilities); /// /// Per-device optional-API capability flags — which of the "this may or may not /// exist on this CNC series" calls succeeded at bootstrap. Drives per-series /// node suppression so a 16i that doesn't export cnc_rdspmaxrpm simply /// doesn't get a Spindle/{name}/MaxRpm node (instead of surfacing /// BadDeviceFailure on every read). /// internal sealed record FocasFixedTreeCapabilities( bool Spindles, // cnc_rdspdlname returned 1+ spindle names bool SpindleLoad, // cnc_rdspload bootstrap probe succeeded bool SpindleMaxRpm, // cnc_rdspmaxrpm bootstrap probe succeeded bool ServoLoad, // cnc_rdsvmeter bootstrap probe returned data bool ProgramInfo, // cnc_exeprgname2 + cnc_rdblkcount + cnc_rdopmode work bool Timers); // cnc_rdtimer works for at least PowerOn 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; } public CancellationTokenSource? RecycleCts { get; set; } public CancellationTokenSource? FixedTreeCts { get; set; } public FocasFixedTreeCache? FixedTreeCache { get; set; } public Dictionary LastFixedSnapshots { get; } = new(StringComparer.OrdinalIgnoreCase); public FocasProgramInfo? LastProgramInfo { get; set; } /// Cached first-axis dynamic snapshot — feeds Program/Number, /MainNumber, /Sequence. public FocasDynamicSnapshot? LastProgramAxisRef { get; set; } public Dictionary LastTimers { get; } = []; public Dictionary LastServoLoads { get; } = new(StringComparer.OrdinalIgnoreCase); public Dictionary LastSpindleLoads { get; } = []; public void DisposeClient() { Client?.Dispose(); Client = null; } } }