using System.Collections.Concurrent; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.OtOpcUa.Commons.Observability; 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, IBatchSubscribable, IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, ISkippedTagReporter, IDisposable, IAsyncDisposable { /// Authored tags this driver could not build into its poll table (Gitea #541). FOCAS has three /// skip paths — an unmappable TagConfig, an unparseable address, and a capability-matrix rejection — and /// #540 was caused by the second of them going unreported while the driver read Healthy. private readonly SkippedTagLedger _skippedTags = new(); /// public SkippedTagReport? GetSkippedTags() => _skippedTags.Snapshot(); /// Mutable so can adopt a re-parsed config (#516). Written only /// on the init path, together with , before either is used. private FocasDriverOptions _options; private readonly string _driverInstanceId; /// Mutable for the same reason as — the Backend config key /// selects it, so the two must move together or a new tag set polls through an old backend. private IFocasClientFactory _clientFactory; /// Re-parses a DriverConfig into every config-derived dependency. Null when the driver /// was constructed directly (tests, or a caller supplying its own backend), which keeps the /// constructor-supplied options authoritative — exactly the prior behaviour. private readonly Func? _rebind; private readonly PollGroupEngine _poll; private readonly ILogger _logger; private readonly Dictionary _devices = new(StringComparer.OrdinalIgnoreCase); // v3: RawPath → definition, case-sensitive ordinal. Built in InitializeAsync from _options.RawTags // via the pure FocasTagDefinitionFactory mapper (the reference is always a RawPath). // Built whole in InitializeAsync and SWAPPED in (Gitea #647 review): the #630-retained poll engine keeps calling // ReadAsync during a re-initialise, and a table cleared and refilled in place reads BadNodeIdUnknown in between. private Dictionary _tagsByRawPath = new(StringComparer.Ordinal); // Resolves a read/write fullReference (always a RawPath in v3) to a tag definition by a single // authored-table hit. A miss is a miss — the driver surfaces BadNodeIdUnknown, never a blob-parse. private readonly EquipmentTagRefResolver _resolver; // Per-tag-name cache of the FocasAddress parsed once at InitializeAsync. ReadAsync / // WriteAsync look up the pre-parsed value instead of re-parsing tag.Address on every hot // call. ConcurrentDictionary is required because the poll loop // (ReadAsync) and the host write thread (WriteAsync) both mutate this cache when resolver- // produced equipment tags are encountered for the first time. private ConcurrentDictionary _parsedAddressesByTagName = new(StringComparer.OrdinalIgnoreCase); /// /// Whether this driver instance runs its per-device fixed-tree poll loop, and therefore /// serves fixed-tree values. Computed at from the config flag /// OR the presence of at least one authored tag. /// /// /// The OR is load-bearing. FocasFixedTreeOptions.Enabled defaults to /// so an upgrade does not grow nodes on configs that never asked for /// them, and the universal browser force-enables it for the duration of a browse only. An /// operator therefore browses the fixed tree, commits leaves from it, deploys — and lands on /// a driver whose loop never starts, so every one of those tags reads Bad with a correct /// address. Authoring a FIXED: tag IS the request for the tree; the config flag stays /// as the way to publish it without authoring anything. /// private bool _fixedTreeActive; /// /// The alarm projection (Gitea #647). Lives as long as the driver — NOT re-created per initialise — because /// it holds what the server was last told about every condition, and the server's condition nodes outlive /// a re-initialise; re-creating it would re-raise (and so un-acknowledge) every active alarm. /// private readonly FocasAlarmProjection _alarmProjection; /// /// RawPath → authored ALARM: tag (Gitea #647). Built in and swapped in /// whole, so the alarm poll loop always sees one consistent table. Alarm tags never enter /// : they have no value to read. /// private IReadOnlyDictionary _alarmTags = new Dictionary(StringComparer.Ordinal); // _health is read/written from multiple threads (ReadAsync, WriteAsync, ProbeLoopAsync). // Volatile.Read/Write ensures every thread sees the latest reference without a lock — the // record is immutable so there is no torn-read risk on the object itself. private DriverHealth _health = new(DriverState.Unknown, null, null); /// Occurs when data changes on a subscribed tag. public event EventHandler? OnDataChange; /// /// Occurs once per poll cycle that produced at least one change, carrying that cycle's /// changed tags. Both data-change events fire by design — this one once per cycle, /// once per changed tag — so a consumer subscribes to EXACTLY /// ONE of the two; subscribing both double-delivers. /// public event EventHandler? OnDataChangeBatch; /// Occurs when a device host connection status changes. public event EventHandler? OnHostStatusChanged; /// Occurs when an alarm event is raised. public event EventHandler? OnAlarmEvent; /// Initializes a new instance of the class with the provided options and dependencies. /// The driver configuration options. /// The unique identifier for this driver instance. /// Optional factory for creating FOCAS client instances. /// Optional logger instance. /// Optional re-parse of a DriverConfig into every config-derived dependency, /// supplied by the factory so can adopt a changed config. Null (direct /// construction, e.g. a test or a caller supplying its own backend) keeps the constructor-supplied /// options + backend. public FocasDriver(FocasDriverOptions options, string driverInstanceId, IFocasClientFactory? clientFactory = null, ILogger? logger = null, Func? rebind = null) { ArgumentNullException.ThrowIfNull(options); _options = options; _driverInstanceId = driverInstanceId; _clientFactory = clientFactory ?? new Wire.WireFocasClientFactory(); _rebind = rebind; _logger = logger ?? NullLogger.Instance; _alarmProjection = new FocasAlarmProjection(this, _logger); _resolver = new EquipmentTagRefResolver( r => Volatile.Read(ref _tagsByRawPath).TryGetValue(r, out var t) ? t : null); _poll = new PollGroupEngine( reader: ReadAsync, onChange: (handle, tagRef, snapshot) => OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)), onError: HandlePollError, backoffCap: PollBackoffCap, onBatch: (handle, items) => OnDataChangeBatch?.Invoke(this, new DataChangeBatchEventArgs(handle, items)), // The engine owns no Meter (Core.Abstractions takes no metrics dependency), so this // closure IS the production consumer of its overrun signal. onOverrun: _ => OtOpcUaTelemetry.PollOverruns.Add(1, new KeyValuePair(OtOpcUaTelemetry.PollOverrunDriverTypeTag, DriverTypeNames.FOCAS), new KeyValuePair(OtOpcUaTelemetry.PollOverrunDriverInstanceTag, driverInstanceId))); } /// True when the supplied DriverConfig JSON carries a real body. The bootstrapper always passes /// a populated document; some unit tests pass "{}" or an empty string to exercise lifecycle shape /// without a config — those keep the constructor-supplied options + backend. private static bool HasConfigBody(string? driverConfigJson) { if (string.IsNullOrWhiteSpace(driverConfigJson)) return false; var trimmed = driverConfigJson.Trim(); return trimmed is not "{}" and not "[]"; } /// Upper bound on the poll-loop failure backoff — adopts the S7-proven 30 s cap fleet-wide (05/STAB-8). private static readonly TimeSpan PollBackoffCap = TimeSpan.FromSeconds(30); /// /// 05/STAB-9 — routes a poll-loop reader failure to the driver health surface: logs it and /// degrades to preserving LastSuccessfulRead. /// Never downgrades a state (a stronger, config-level signal). /// /// The exception caught by the poll engine. internal void HandlePollError(Exception ex) { _logger.LogWarning(ex, "FOCAS poll reader failed. Driver={DriverInstanceId}", _driverInstanceId); var current = Volatile.Read(ref _health); if (current.State != DriverState.Faulted) Volatile.Write(ref _health, new DriverHealth(DriverState.Degraded, current.LastSuccessfulRead, ex.Message)); } /// public string DriverInstanceId => _driverInstanceId; /// public string DriverType => "FOCAS"; /// /// Opens the configured CNC handles and builds the authored tag table. /// is re-parsed here when the driver was built by /// the factory (Gitea #516), and the re-parse is all-or-nothing: the typed options and the /// IFocasClientFactory the Backend key selects come from one ParseBinding call /// and are adopted together. That pairing is the point — adopting options alone would poll a NEW /// device/tag set through the OLD backend, which is why this driver was excluded from the first /// #516 pass. /// A driver constructed directly (every unit test, or a caller supplying its own backend) gets /// no rebinder and keeps its constructor-supplied pair. DriverSpawnPlanner's stop + respawn /// remains the outer guarantee. /// /// The driver configuration JSON (see remarks — not re-parsed). /// Cancellation token for the operation. /// A completed task. public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) { // #630: release the previous device states' loops + clients BEFORE they are overwritten (the actor's // AdoptConfigDuringInit calls this without a teardown first). Not ShutdownAsync: that also drops _poll // and subscriptions, which are owned by the shutdown path (the actor re-subscribes after every successful init). await ReleaseDeviceStatesAsync().ConfigureAwait(false); ResetAlarmHealth(); Volatile.Write(ref _health, new DriverHealth(DriverState.Initializing, null, null)); try { // #516: adopt a changed config. Options and the backend client factory are re-derived by ONE // parse and assigned together — a partial adoption would poll a new device/tag set through the // old backend. A null rebinder (direct construction) or an empty/placeholder document keeps the // constructor-supplied pair, which is what every lifecycle test passing "{}" relies on. if (_rebind is not null && HasConfigBody(driverConfigJson)) { var binding = _rebind(driverConfigJson); _options = binding.Options; _clientFactory = binding.ClientFactory; } // Fail fast if the factory is a stub/unimplemented backend — the operator must // see an actionable error at init rather than a phantom-Healthy driver that fails // every read/write/subscribe silently. _clientFactory.EnsureUsable(); 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); } // Build the RawPath → definition table from the authored raw tags. Each entry's TagConfig // blob is mapped by the pure factory; the entry's WriteIdempotent flag + DeviceName are // threaded onto the def (they live on the RawTagEntry, not inside the blob). Per-tag misses // are tolerant (skipped + logged → BadNodeIdUnknown at read, "a miss is a miss") — a single // bad tag must not fail the whole driver init. The ONE hard failure is an unknown device // segment: a tag routed to a device not in the Devices list is a driver-level config error // that fails fast (Driver.FOCAS-003). _skippedTags.BeginRebuild(_options.RawTags.Count); // A re-initialise without teardown (#630) rebuilds the tables from scratch: a tag the new config // removed — or moved between the value and alarm tables (#647) — must not survive in the old one. // Built into fresh tables and swapped in below, never cleared in place under a live reader. var tagsByRawPath = new Dictionary(StringComparer.Ordinal); var parsedAddresses = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); var alarmTags = new Dictionary(StringComparer.Ordinal); foreach (var entry in _options.RawTags) { if (!FocasTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def)) { _skippedTags.Skip(entry.RawPath, "TagConfig did not map to a definition"); _logger.LogWarning( "FOCAS tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}", _driverInstanceId, entry.RawPath); continue; } // options.Devices already IS the per-device DeviceConfig (DriverDeviceConfigMerger rebuilds it at // deploy time, one object per Device row with DeviceName = the RawPath device segment), so the // host is recovered by DeviceName against that list. var deviceHost = ResolveDeviceHost(entry.DeviceName); if (deviceHost is null) throw new InvalidOperationException( $"FOCAS tag '{entry.RawPath}' references device '{entry.DeviceName}' " + $"which is not in the Devices list. Check for a typo (e.g. wrong port or hostname)."); if (!_devices.TryGetValue(deviceHost, out var device)) throw new InvalidOperationException( $"FOCAS tag '{entry.RawPath}' references device '{entry.DeviceName}' " + $"which is not in the Devices list. Check for a typo (e.g. wrong port or hostname)."); // Per-tag address / capability-matrix validation is tolerant: an unparseable address or a // capability-matrix violation skips the tag (→ BadNodeIdUnknown at read) rather than failing // the whole init (R2-11 05/UNDER-6 — the same pre-flight, surfacing a resolver miss). var parsed = FocasAddress.TryParse(def.Address); if (parsed is null) { _skippedTags.Skip(entry.RawPath, $"invalid Address '{def.Address}'"); _logger.LogWarning( "FOCAS tag has invalid Address '{Address}'; skipping. Driver={DriverInstanceId} RawPath={RawPath}", def.Address, _driverInstanceId, entry.RawPath); continue; } if (FocasCapabilityMatrix.Validate(device.Options.Series, parsed) is { } reason) { _skippedTags.Skip(entry.RawPath, $"'{def.Address}' rejected by capability matrix: {reason}"); _logger.LogWarning( "FOCAS tag '{RawPath}' ({Address}) rejected by capability matrix: {Reason}; skipping. Driver={DriverInstanceId}", entry.RawPath, def.Address, reason, _driverInstanceId); continue; } // Gitea #647: an ALARM: address and the cross-driver alarm object only work together — the object // is what makes the host materialise a condition at this RawPath, the address is the only thing // this driver can drive one from. Either alone does nothing, so it is skipped and reported. if (parsed.Kind == FocasAreaKind.Alarm || def.Alarm is not null) { if (parsed.Kind == FocasAreaKind.Alarm && def.Alarm is not null) { alarmTags[entry.RawPath] = new FocasAlarmTag( entry.RawPath, deviceHost, parsed.AlarmSelector!, def.Alarm.Severity); continue; } var why = parsed.Kind == FocasAreaKind.Alarm ? $"'{def.Address}' is an alarm selector but the tag has no 'alarm' object" : $"the tag carries an 'alarm' object but '{def.Address}' is not an ALARM: selector"; _skippedTags.Skip(entry.RawPath, why); _logger.LogWarning("FOCAS tag {RawPath}: {Reason}; skipping. Driver={DriverInstanceId}", entry.RawPath, why, _driverInstanceId); continue; } tagsByRawPath[entry.RawPath] = def with { DeviceHostAddress = deviceHost, WriteIdempotent = entry.WriteIdempotent, }; // Cache the parsed FocasAddress so ReadAsync / WriteAsync don't re-parse on every // hot-path call. The address string has already been validated by FocasAddress.TryParse // above; reusing the parsed record avoids per-tick allocs on subscription pollers. parsedAddresses[entry.RawPath] = parsed; } // Publish the skip tally only once the whole table is built. An unknown-device throw above // leaves the previous snapshot standing rather than a truncated one that would read as // "we now skip fewer tags". _skippedTags.Commit(); Volatile.Write(ref _parsedAddressesByTagName, parsedAddresses); Volatile.Write(ref _tagsByRawPath, tagsByRawPath); Volatile.Write(ref _alarmTags, alarmTags); if (_options.Probe.Enabled) { foreach (var state in _devices.Values) { state.ProbeCts = new CancellationTokenSource(); var ct = state.ProbeCts.Token; state.ProbeTask = 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; state.RecycleTask = Task.Run(() => RecycleLoopAsync(state, ct), ct); } } // See _fixedTreeActive: an authored FIXED: tag arms the loop on its own, because the // config flag defaults off and the operator who committed those tags has already said // what they want. _fixedTreeActive = _options.FixedTree.Enabled || _parsedAddressesByTagName.Values.Any(a => a.Kind == FocasAreaKind.Fixed); if (_fixedTreeActive) { foreach (var state in _devices.Values) { state.FixedTreeCts = new CancellationTokenSource(); var ct = state.FixedTreeCts.Token; state.FixedTreeTask = Task.Run(() => FixedTreeLoopAsync(state, ct), ct); } } // Restart the alarm poll a re-initialise paused, against the tables just rebuilt (Gitea #647). A // driver with no subscription yet starts nothing — the actor subscribes once it is Connected. await _alarmProjection.ResumeAsync().ConfigureAwait(false); Volatile.Write(ref _health, new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null)); } catch (Exception ex) { Volatile.Write(ref _health, new DriverHealth(DriverState.Faulted, null, ex.Message)); throw; } } /// /// Cancels, bounded-joins and disposes every existing device state (probe / recycle / fixed-tree /// loops + client) and the alarm projection, then clears the table. Device part of /// only (no poll / subscription teardown). /// private async Task ReleaseDeviceStatesAsync() { // Pause, not dispose (Gitea #647): the subscription and what the server was told survive the rebuild. await _alarmProjection.PauseAsync().ConfigureAwait(false); if (_devices.Count == 0) return; var loops = new List(); foreach (var state in _devices.Values) { try { state.ProbeCts?.Cancel(); } catch (Exception ex) { _logger.LogDebug(ex, "Cancelling probe CTS for {Host} failed", state.Options.HostAddress); } try { state.RecycleCts?.Cancel(); } catch (Exception ex) { _logger.LogDebug(ex, "Cancelling recycle CTS for {Host} failed", state.Options.HostAddress); } try { state.FixedTreeCts?.Cancel(); } catch (Exception ex) { _logger.LogDebug(ex, "Cancelling fixed-tree CTS for {Host} failed", state.Options.HostAddress); } if (state.ProbeTask is { } pt) loops.Add(pt); if (state.RecycleTask is { } rt) loops.Add(rt); if (state.FixedTreeTask is { } ft) loops.Add(ft); } if (loops.Count > 0) { var all = Task.WhenAll(loops); var finished = await Task.WhenAny(all, Task.Delay(LoopJoinTimeout, CancellationToken.None)).ConfigureAwait(false); if (finished != all) _logger.LogWarning("FOCAS re-initialise: {Count} background loop(s) did not stop within {Timeout}; proceeding", loops.Count, LoopJoinTimeout); } foreach (var state in _devices.Values) { state.ProbeCts?.Dispose(); state.ProbeCts = null; state.ProbeTask = null; state.RecycleCts?.Dispose(); state.RecycleCts = null; state.RecycleTask = null; state.FixedTreeCts?.Dispose(); state.FixedTreeCts = null; state.FixedTreeTask = null; state.DisposeClient(); } _devices.Clear(); } /// public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) { await ShutdownAsync(cancellationToken).ConfigureAwait(false); await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false); } /// Upper bound on how long shutdown waits for the background loops to observe cancellation. internal static readonly TimeSpan LoopJoinTimeout = TimeSpan.FromSeconds(5); /// public async Task ShutdownAsync(CancellationToken cancellationToken) { await _poll.DisposeAsync().ConfigureAwait(false); // Pause, not dispose (Gitea #647) — see _alarmProjection. A later InitializeAsync resumes it. await _alarmProjection.PauseAsync().ConfigureAwait(false); // STAB-23: cancel every loop first, JOIN them (bounded), and only then dispose the CTSs and the // client. Fire-and-forget loops used to outlive shutdown: one parked in EnsureConnectedAsync could // finish its connect AFTER DisposeClient and leave a live socket on a shut-down driver. var loops = new List(); foreach (var state in _devices.Values) { // Cancel-then-dispose can race in tight shutdown loops; swallowing is intentional // but we now log the cause so a noisy shutdown leaves a Debug trace. try { state.ProbeCts?.Cancel(); } catch (Exception ex) { _logger.LogDebug(ex, "Cancelling probe CTS for {Host} failed", state.Options.HostAddress); } try { state.RecycleCts?.Cancel(); } catch (Exception ex) { _logger.LogDebug(ex, "Cancelling recycle CTS for {Host} failed", state.Options.HostAddress); } try { state.FixedTreeCts?.Cancel(); } catch (Exception ex) { _logger.LogDebug(ex, "Cancelling fixed-tree CTS for {Host} failed", state.Options.HostAddress); } if (state.ProbeTask is { } pt) loops.Add(pt); if (state.RecycleTask is { } rt) loops.Add(rt); if (state.FixedTreeTask is { } ft) loops.Add(ft); } if (loops.Count > 0) { var all = Task.WhenAll(loops); var finished = await Task.WhenAny(all, Task.Delay(LoopJoinTimeout, CancellationToken.None)).ConfigureAwait(false); if (finished != all) _logger.LogWarning("FOCAS shutdown: {Count} background loop(s) did not stop within {Timeout}; proceeding with teardown", loops.Count, LoopJoinTimeout); } foreach (var state in _devices.Values) { state.ProbeCts?.Dispose(); state.ProbeCts = null; state.ProbeTask = null; state.RecycleCts?.Dispose(); state.RecycleCts = null; state.RecycleTask = null; state.FixedTreeCts?.Dispose(); state.FixedTreeCts = null; state.FixedTreeTask = null; state.DisposeClient(); } _devices.Clear(); Volatile.Write(ref _tagsByRawPath, new Dictionary(StringComparer.Ordinal)); Volatile.Write(ref _alarmTags, new Dictionary(StringComparer.Ordinal)); _resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry _parsedAddressesByTagName.Clear(); _fixedTreeActive = false; ResetAlarmHealth(); Volatile.Write(ref _health, new DriverHealth(DriverState.Unknown, Volatile.Read(ref _health).LastSuccessfulRead, null)); } /// /// /// The worst of the value path () and the alarm path (), /// computed together (Gitea #647 re-review R3). is the later of /// the two paths' last successes: while either path still reads, the comms-loss watchdog stays quiet; when both /// have stopped it is frozen. Faulted / Unknown / Initializing come from the value (lifecycle) path unchanged. /// public DriverHealth GetHealth() { var value = Volatile.Read(ref _health); var ticks = Interlocked.Read(ref _alarmLastSuccessTicks); DateTime? alarmLast = ticks == 0 ? null : new DateTime(ticks, DateTimeKind.Utc); var last = value.LastSuccessfulRead is { } v && (alarmLast is null || v >= alarmLast) ? v : alarmLast; if (value.State is not (DriverState.Healthy or DriverState.Degraded)) return value; if (value.State == DriverState.Healthy && !_alarmTransportFailures.IsEmpty) { var reason = _alarmTransportFailures.OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase).First().Value; return new DriverHealth(DriverState.Degraded, last, reason); } return value.LastSuccessfulRead == last ? value : value with { LastSuccessfulRead = last }; } /// Gets the number of configured devices. internal int DeviceCount => _devices.Count; /// Gets the state of a device by host address. /// The host address of the device. /// The device state if found; otherwise null. internal DeviceState? GetDeviceState(string hostAddress) => _devices.TryGetValue(hostAddress, out var s) ? s : null; /// Test seam — the resolved options the factory built this driver from. internal FocasDriverOptions Options => _options; /// Test seam — returns true when a parsed for the given /// reference is present in the address cache (both authored and equipment-tag paths). /// The tag or equipment-tag reference to check. /// if a parsed address is cached for the reference; otherwise . internal bool IsParsedAddressCached(string reference) => _parsedAddressesByTagName.ContainsKey(reference); // Recovers the host by matching the RawTagEntry's DeviceName against the configured Devices list (which // DriverDeviceConfigMerger fills from each Device row's DeviceConfig). By DeviceName, falling back to // HostAddress (the Client CLI passes the host as the DeviceName); for a single-device driver an empty // DeviceName routes to the sole device. Returns null when no device matches — the caller fails the tag fast (Driver.FOCAS-003). private string? ResolveDeviceHost(string deviceName) { foreach (var d in _options.Devices) { if (string.Equals(d.DeviceName ?? d.HostAddress, deviceName, StringComparison.OrdinalIgnoreCase) || string.Equals(d.HostAddress, deviceName, StringComparison.OrdinalIgnoreCase)) return d.HostAddress; } if (string.IsNullOrEmpty(deviceName) && _options.Devices.Count == 1) return _options.Devices[0].HostAddress; return null; } // Resolves a tag definition to its parsed FocasAddress, caching the result so that // equipment tags (resolver-produced, not seeded at InitializeAsync) don't re-parse the // address string on every ReadAsync / WriteAsync hot-path call. // Throwing inside the GetOrAdd factory propagates the exception to the caller and does // NOT store anything in the dictionary — consistent with the existing "fail fast on a // malformed address" behaviour from the init-time validation of authored tags. private FocasAddress ResolveParsedAddress(FocasTagDefinition def) => _parsedAddressesByTagName.GetOrAdd(def.Name, _ => FocasAddress.TryParse(def.Address) ?? throw new InvalidOperationException( $"FOCAS tag '{def.Name}' has malformed Address '{def.Address}'.")); // ---- IReadable ---- /// public async Task> ReadAsync( IReadOnlyList fullReferences, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(fullReferences); var now = DateTime.UtcNow; var results = new DataValueSnapshot[fullReferences.Count]; // #266 — merge adjacent / near PMC reads of one device into fewer pmc_rdpmcrng calls. Indices it // serves are marked here and skipped by the per-tag loop below; everything else (including every // single-tag range and any range the CNC rejected) takes the unchanged per-tag path. var served = _options.PmcCoalesce.Enabled ? await CoalescePmcReadsAsync(fullReferences, results, now, cancellationToken).ConfigureAwait(false) : null; for (var i = 0; i < fullReferences.Count; i++) { if (served is not null && served[i]) continue; 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. // // This branch matches a reference that IS a "{host}/{path}" fixed-tree key. Authored // tags do not take it — their reference is a RawPath — and are served further down by // their FIXED: address instead (Gitea #540). if (_fixedTreeActive && TryReadFixedTree(reference, now) is { } fx) { results[i] = fx; continue; } // An ALARM: tag is a Part 9 condition, not a value (Gitea #647) — there is nothing to read. if (Volatile.Read(ref _alarmTags).ContainsKey(reference)) { results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNotSupported, null, now); continue; } if (!_resolver.TryResolve(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 parsed = ResolveParsedAddress(def); // A FIXED: tag names a field of this device's fixed-tree snapshot, which the // fixed-tree loop already maintains — there is no area read to issue, and no // connection to force. A null here means the path resolves to nothing on THIS // device (an unsupported capability, a renamed axis, a typo), which is a genuine // BadNodeIdUnknown; a device that has not answered yet reports a comms fault // instead, because the address may well be fine. if (parsed.Kind == FocasAreaKind.Fixed) { results[i] = TryReadFixedTreeOnDevice(device, parsed.FixedPath!, now) ?? new DataValueSnapshot( null, device.LastFixedPollUtc is null ? FocasStatusMapper.BadCommunicationError : FocasStatusMapper.BadNodeIdUnknown, null, now); continue; } var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false); var (value, status) = await client.ReadAsync(parsed, def.DataType, cancellationToken).ConfigureAwait(false); results[i] = new DataValueSnapshot(value, status, now, now); if (status == FocasStatusMapper.Good) Volatile.Write(ref _health, new DriverHealth(DriverState.Healthy, now, null)); else Volatile.Write(ref _health, new DriverHealth(DriverState.Degraded, Volatile.Read(ref _health).LastSuccessfulRead, $"FOCAS status 0x{status:X8} reading {reference}")); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch (OperationCanceledException) { // Per-call timeout (not external cancellation) — the read stalled past the device // Timeout budget. Surface a recoverable comm error so the BadWaitingForInitialData // seed is overwritten and health degrades, instead of the read hanging forever. results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now); Volatile.Write(ref _health, new DriverHealth(DriverState.Degraded, Volatile.Read(ref _health).LastSuccessfulRead, $"FOCAS read timed out for {reference}")); } catch (Exception ex) { results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now); Volatile.Write(ref _health, new DriverHealth(DriverState.Degraded, Volatile.Read(ref _health).LastSuccessfulRead, ex.Message)); } } return results; } /// /// PMC coalescing pre-pass (Gitea #266). Groups the batch's PMC tags by device, plans merged ranges /// () and serves every multi-member range with ONE /// pmc_rdpmcrng, slicing each tag's value out of the returned slots. /// /// /// Failure handling is the point of the design. A rejected range is NOT mis-attributed to its members: /// /// a comms-level failure (timeout / transport) fails every member with that status — the /// link is down, so re-reading each member would multiply timeouts; /// any other non-Good status (typically a spare "gap" byte that is not a valid PMC address) /// leaves the members UNSERVED, so the per-tag loop reads them individually and each gets its own /// true status — and the range is remembered so later polls skip the doomed merged read. /// /// Returns null when nothing was served. /// private async Task CoalescePmcReadsAsync( IReadOnlyList references, DataValueSnapshot[] results, DateTime now, CancellationToken ct) { Dictionary>? byDevice = null; for (var i = 0; i < references.Count; i++) { try { var reference = references[i]; if (_fixedTreeActive && TryReadFixedTree(reference, now) is not null) continue; if (!_resolver.TryResolve(reference, out var def)) continue; if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) continue; var parsed = ResolveParsedAddress(def); if (parsed.Kind != FocasAreaKind.Pmc || parsed.PmcLetter is null) continue; (byDevice ??= []).TryAdd(device, []); byDevice[device].Add(new FocasPmcReadItem(i, parsed.PmcLetter, parsed.Number, def.DataType, parsed.BitIndex)); } catch (Exception ex) when (ex is not OperationCanceledException) { /* malformed / unresolvable — the per-tag loop reports it with its usual status */ } } if (byDevice is null) return null; bool[]? served = null; foreach (var (device, items) in byDevice) { if (items.Count < 2) continue; var plans = FocasPmcCoalescer.Plan(items, _options.PmcCoalesce.MaxSpanBytes, _options.PmcCoalesce.MaxGapBytes); if (!plans.Any(p => p.Members.Count >= 2 && !device.CoalesceRejected.ContainsKey(p.Key))) continue; // One connect attempt per device per batch: if it fails, every PMC tag of the device fails with // that status here instead of paying a connect attempt per plan and again per tag. IFocasClient client; try { client = await EnsureConnectedAsync(device, ct).ConfigureAwait(false); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } catch (Exception) { FailMembers(items, FocasStatusMapper.BadCommunicationError, references, results, now, ref served, $"FOCAS connect failed reading PMC tags of {device.Options.HostAddress}"); continue; } foreach (var plan in plans) { if (plan.Members.Count < 2) continue; if (device.CoalesceRejected.ContainsKey(plan.Key)) continue; var members = plan.Members; try { var (slots, status) = await client .ReadPmcSlotsAsync(plan.Letter, plan.SlotType, plan.Start, plan.End, ct) .ConfigureAwait(false); if (status == FocasStatusMapper.Good && slots is not null) { foreach (var m in members) { var slot = plan.SlotOf(m); results[m.Index] = slot < slots.Count ? new DataValueSnapshot(FocasPmcCoalescer.MapSlot(slots[slot], m.Type, m.BitIndex), status, now, now) : new DataValueSnapshot(null, FocasStatusMapper.BadOutOfRange, now, now); (served ??= new bool[references.Count])[m.Index] = true; } Volatile.Write(ref _health, new DriverHealth(DriverState.Healthy, now, null)); } else { // The CNC REFUSED the merged range (any return code, mapped or not). Don't guess which // member is at fault: let the per-tag loop give each its own answer. Remember the range // so later polls skip the doomed read — except for a transient refusal (EW_BUSY maps to // BadDeviceFailure), which says nothing about the range. if (status != FocasStatusMapper.BadDeviceFailure) device.CoalesceRejected[plan.Key] = 0; _logger.LogDebug( "FOCAS PMC range {Key} on {Host} rejected (0x{Status:X8}) — reading its {Count} tags individually", plan.Key, device.Options.HostAddress, status, members.Count); } } catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } catch (Exception) { // Transport failure / timeout: the link is suspect, so fail the members without per-tag retries. FailMembers(members, FocasStatusMapper.BadCommunicationError, references, results, now, ref served, $"FOCAS transport failure reading PMC range {plan.Key}"); } } } return served; void FailMembers(IEnumerable failed, uint status, IReadOnlyList refs, DataValueSnapshot[] res, DateTime stamp, ref bool[]? servedRef, string reason) { foreach (var m in failed) { res[m.Index] = new DataValueSnapshot(null, status, null, stamp); (servedRef ??= new bool[refs.Count])[m.Index] = true; } Volatile.Write(ref _health, new DriverHealth(DriverState.Degraded, Volatile.Read(ref _health).LastSuccessfulRead, $"{reason} (0x{status:X8})")); } } // ---- 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]; // An ALARM: tag is a condition, and this driver never writes to the CNC's alarm state (#647). if (Volatile.Read(ref _alarmTags).ContainsKey(w.FullReference)) { results[i] = new WriteResult(FocasStatusMapper.BadNotWritable); continue; } if (!_resolver.TryResolve(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 parsed = ResolveParsedAddress(def); // Nothing in the fixed tree is a writable location on the CNC — it is a read-out of // machine state (identity, positions, program, timers). Refuse before opening a // connection rather than sending a write the wire would reject anyway. if (parsed.Kind == FocasAreaKind.Fixed) { results[i] = new WriteResult(FocasStatusMapper.BadNotWritable); continue; } var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false); var status = await client.WriteAsync(parsed, def.DataType, w.Value, cancellationToken).ConfigureAwait(false); results[i] = new WriteResult(status); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch (OperationCanceledException) { // Per-call timeout (not external cancellation) — the write stalled past the device // Timeout budget. Surface a recoverable comm error rather than aborting the batch. results[i] = new WriteResult(FocasStatusMapper.BadCommunicationError); Volatile.Write(ref _health, new DriverHealth(DriverState.Degraded, Volatile.Read(ref _health).LastSuccessfulRead, $"FOCAS write timed out for {w.FullReference}")); } catch (NotSupportedException nse) { results[i] = new WriteResult(FocasStatusMapper.BadNotSupported); Volatile.Write(ref _health, new DriverHealth(DriverState.Degraded, Volatile.Read(ref _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); Volatile.Write(ref _health, new DriverHealth(DriverState.Degraded, Volatile.Read(ref _health).LastSuccessfulRead, ex.Message)); } } return results; } // ---- ITagDiscovery ---- /// // Retry-until-stable: the FixedTree subtree is filled in asynchronously by // FixedTreeLoopAsync a couple of seconds AFTER connect, so the first post-connect // DiscoverAsync pass would miss it — the host must re-run discovery until the captured // node set is non-empty and stable. public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.UntilStable; /// The FixedTree device folder is real enumeration — config-gated on FixedTree.Enabled, /// which the universal browser's PatchForBrowse turns on. FOCAS is the UntilStable driver: the /// FixedTree cache fills a couple of seconds post-connect, so the browser's settle loop re-captures /// until the node set is non-empty and stable. public bool SupportsOnlineDiscovery => true; /// 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 (_fixedTreeActive && _devices.TryGetValue(device.HostAddress, out var state) && state.FixedTreeCache is { } cache) { var identity = deviceFolder.Folder("Identity", "Identity"); EmitIdentityVariable(identity, "SeriesNumber", FocasDriverDataType.String); EmitIdentityVariable(identity, "Version", FocasDriverDataType.String); EmitIdentityVariable(identity, "MaxAxes", FocasDriverDataType.Int32); EmitIdentityVariable(identity, "CncType", FocasDriverDataType.String); EmitIdentityVariable(identity, "MtType", FocasDriverDataType.String); EmitIdentityVariable(identity, "AxisCount", FocasDriverDataType.Int32); var axesFolder = deviceFolder.Folder("Axes", "Axes"); for (var a = 0; a < cache.Axes.Count; a++) { // The disambiguated segment, not the raw Display — a CNC reporting two // axes both named AA gets AA_1 / AA_2 folders instead of two identical // siblings the commit gate must refuse (#548). var segment = cache.AxisSegments[a]; var axisFolder = axesFolder.Folder(segment, segment); EmitAxisVariable(axisFolder, segment, "AbsolutePosition"); EmitAxisVariable(axisFolder, segment, "MachinePosition"); EmitAxisVariable(axisFolder, segment, "RelativePosition"); EmitAxisVariable(axisFolder, segment, "DistanceToGo"); if (cache.Capabilities.ServoLoad) EmitAxisVariable(axisFolder, segment, "ServoLoad"); } EmitAxisVariable(axesFolder, "FeedRate", "Actual"); EmitAxisVariable(axesFolder, "SpindleSpeed", "Actual"); // Spindle subtree — one folder per discovered spindle, suppressed // entirely on series that don't export cnc_rdspdlname. Per-spindle // Load + MotorSpeedPct (both cnc_rdspmeter percentages of full scale) // 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 name = cache.SpindleSegments[i]; var spindleFolder = spindleRoot.Folder(name, name); if (cache.Capabilities.SpindleLoad) EmitFixedVariable(spindleFolder, $"Spindle/{name}", "Load", DriverDataType.Float64); if (cache.Capabilities.SpindleMotorSpeed) EmitFixedVariable(spindleFolder, $"Spindle/{name}", "MotorSpeedPct", DriverDataType.Float64); } } // Fixed-tree T2 — Program + OperationMode subtrees (gated on capability). if (cache.Capabilities.ProgramInfo) { var program = deviceFolder.Folder("Program", "Program"); EmitFixedVariable(program, "Program", "Name", DriverDataType.String); EmitFixedVariable(program, "Program", "ONumber", DriverDataType.Int32); EmitFixedVariable(program, "Program", "Number", DriverDataType.Int32); EmitFixedVariable(program, "Program", "MainNumber", DriverDataType.Int32); EmitFixedVariable(program, "Program", "Sequence", DriverDataType.Int32); EmitFixedVariable(program, "Program", "BlockCount", DriverDataType.Int32); var opMode = deviceFolder.Folder("OperationMode", "OperationMode"); EmitFixedVariable(opMode, "OperationMode", "Mode", DriverDataType.Int32); EmitFixedVariable(opMode, "OperationMode", "ModeText", DriverDataType.String); } // Fixed-tree T2.5 — Status subtree (cnc_statinfo flags, #257), gated on its own probe. if (cache.Capabilities.Status) { var status = deviceFolder.Folder("Status", "Status"); foreach (var field in StatusFields) EmitFixedVariable(status, "Status", field, DriverDataType.Int32); EmitFixedVariable(status, "Status", "RunText", DriverDataType.String); } // Fixed-tree T3 — Timers subtree (power-on / operating / cutting / cycle). if (cache.Capabilities.Timers) { var timers = deviceFolder.Folder("Timers", "Timers"); EmitFixedVariable(timers, "Timers", "PowerOnSeconds", DriverDataType.Float64); EmitFixedVariable(timers, "Timers", "OperatingSeconds", DriverDataType.Float64); EmitFixedVariable(timers, "Timers", "CuttingSeconds", DriverDataType.Float64); EmitFixedVariable(timers, "Timers", "CycleSeconds", DriverDataType.Float64); } } var tagsForDevice = _tagsByRawPath.Values.Where(t => string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase)); foreach (var tag in tagsForDevice) { // The wire backend is read-only by design (WriteAsync returns BadNotWritable // for every address) so all FOCAS tags are advertised as ViewOnly regardless // of the Writable config field. Advertising Operate would mislead OPC UA // clients and the DriverNodeManager ACL layer into granting write permission // on nodes that can never be written. deviceFolder.Variable(tag.Name, tag.Name, new DriverAttributeInfo( FullName: tag.Name, DriverDataType: tag.DataType.ToDriverDataType(), IsArray: false, ArrayDim: null, SecurityClass: SecurityClassification.ViewOnly, IsHistorized: false, IsAlarm: false, WriteIdempotent: tag.WriteIdempotent, NativeDataType: tag.DataType.ToString())); } } return Task.CompletedTask; } private enum FocasDriverDataType { String, Int32, Float64 } /// /// Emit one Identity leaf. /// /// /// /// The emitted FullName is the authorable address, not the runtime cache key. /// A captured browse leaf's id IS its FullName, and that string is what both the /// /raw browse-commit mapper and the manual picker's tree-select write into the tag's /// TagConfig.address. Emitting the host-prefixed cache key therefore authored an /// address no FOCAS parser accepts: the driver skipped every such tag at init and each one /// read BadNodeIdUnknown forever, while the deploy sealed green and driver health /// stayed Healthy (Gitea #540). /// /// /// The host is dropped rather than stripped later because a tag's device is already carried /// by its raw-tag Device row. Two routing channels for one fact is what created the defect; /// one channel is the fix. keeps the host for the internal /// per-device caches, which do need to distinguish devices. /// /// /// The address-space folder to emit into. /// The identity field name. /// The field's data type. private static void EmitIdentityVariable( IAddressSpaceBuilder folder, string field, FocasDriverDataType type) { var fullName = FocasAddress.ForFixedPath($"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, NativeDataType: NativeName(type))); } /// Emit one Axes leaf. See on the address shape. /// The address-space folder to emit into. /// The axis display name. /// The per-axis field name. private static void EmitAxisVariable( IAddressSpaceBuilder folder, string axisName, string field) { var fullName = FocasAddress.ForFixedPath($"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, NativeDataType: nameof(FocasDataType.Float64))); } /// /// The name a fixed-tree node binds by, for the browse-commit /// path (Gitea #536). The fixed tree only ever emits these three, and each has an /// identically-named member — the driver-agnostic /// cannot be reversed (Byte/Int16/Int32 all collapse to /// ), so the native name is carried rather than derived. /// private static string NativeName(FocasDriverDataType type) => type switch { FocasDriverDataType.Int32 => nameof(FocasDataType.Int32), FocasDriverDataType.Float64 => nameof(FocasDataType.Float64), _ => nameof(FocasDataType.String), }; /// /// Emit a variable under a named fixed-tree folder (Program, OperationMode, /// …). Address shape is FIXED:{folderPath}/{field} — see /// . /// /// The address-space folder to emit into. /// The fixed-tree folder path. /// The field name. /// The field's data type. private static void EmitFixedVariable( IAddressSpaceBuilder folder, string folderPath, string field, DriverDataType type) { var fullName = FocasAddress.ForFixedPath($"{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, NativeDataType: NativeName(type switch { DriverDataType.Int32 => FocasDriverDataType.Int32, DriverDataType.Float64 => FocasDriverDataType.Float64, _ => FocasDriverDataType.String, }))); } /// /// Canonical runtime cache key 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. /// /// /// This is an internal key, not an authorable address — see /// for why the two are deliberately different strings /// (Gitea #540). /// /// The host address of the device. /// The path within the fixed tree. /// The canonical cache key for the node. 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 async Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken) { // Async teardown: the engine drains the in-flight poll without blocking the caller's // thread — this runs inside a DriverInstanceActor mailbox turn. await _poll.UnsubscribeAsync(handle).ConfigureAwait(false); } // ---- 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 { // The probe bypasses the connect backoff — its own interval is the bounded recovery // cadence, and a successful probe connect resets the window for the data + loop paths. var client = await EnsureConnectedAsync(state, ct, bypassBackoff: true).ConfigureAwait(false); // Apply Probe.Timeout so a hung CNC socket gets cancelled at the configured // budget rather than blocking until the OS TCP timeout. // TimeSpan.Zero / negative means "no per-probe timeout" — fall back to the loop // cancellation token unmodified. var probeTimeout = _options.Probe.Timeout; if (probeTimeout > TimeSpan.Zero) { using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct); linked.CancelAfter(probeTimeout); success = await client.ProbeAsync(linked.Token).ConfigureAwait(false); } else { success = await client.ProbeAsync(ct).ConfigureAwait(false); } } catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; } catch (OperationCanceledException ex) { // Per-probe timeout fired — the loop is still alive. Treat as a failed probe so // the host state transitions to Stopped, and log so silent timeouts are visible. _logger.LogDebug(ex, "FOCAS probe timed out for {Host} after {Timeout}", state.Options.HostAddress, _options.Probe.Timeout); } catch (Exception ex) { /* connect-failure path already disposed + cleared the client */ _logger.LogDebug(ex, "FOCAS probe failed for {Host}", state.Options.HostAddress); } 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 spindleMotorSpeeds = await SafeProbe(() => client.GetSpindleMotorSpeedsAsync(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 statusProbe = await SafeTryProbe(() => client.GetStatusAsync(ct)); var spindleLoad = await SafeProbe(() => client.GetSpindleLoadsAsync(ct), []); var caps = new FocasFixedTreeCapabilities( Spindles: spindles.Count > 0, SpindleLoad: spindleLoad.Count > 0, SpindleMotorSpeed: spindleMotorSpeeds.Count > 0, ServoLoad: servoLoads.Count > 0, ProgramInfo: programInfo is not null, Timers: timer is not null, Status: statusProbe is not null); state.LastStatus = statusProbe; state.FixedTreeCache = new FocasFixedTreeCache(sys, [.. axes], [.. spindles], caps); // The bootstrap IS an answer from the device, and Identity comes entirely from it — // so stamp liveness here as well as per tick, or a CNC whose axis poll never // succeeds would serve a perfectly valid sysinfo as stale. state.LastFixedPollUtc = DateTime.UtcNow; } catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; } catch (Exception ex) { _logger.LogDebug(ex, "FOCAS fixed-tree bootstrap failed for {Host} — retrying", state.Options.HostAddress); try { await Task.Delay(TimeSpan.FromSeconds(2), ct).ConfigureAwait(false); } catch (OperationCanceledException) { return; } } } // Prime the spindle-meter caches from bootstrap if supported — avoids a // "tree is there but reads say BadNodeIdUnknown" window on startup. if (state.FixedTreeCache?.Capabilities is { SpindleLoad: true } or { SpindleMotorSpeed: true }) { try { var client2 = await EnsureConnectedAsync(state, ct).ConfigureAwait(false); await PollSpindleMetersAsync(state, client2, ct).ConfigureAwait(false); } catch (Exception ex) { /* first-tick poll will retry */ _logger.LogDebug(ex, "FOCAS bootstrap spindle-loads prime failed for {Host} — first poll tick will retry", state.Options.HostAddress); } } var programPollDue = DateTime.MinValue; var timerPollDue = DateTime.MinValue; var statusPollDue = 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 snap = await client.ReadDynamicAsync(axisIndex, ct).ConfigureAwait(false); // Keyed by the disambiguated segment, matching what DiscoverAsync emitted — // two same-named axes must not share (and overwrite) one cache slot (#548). PublishAxisSnapshot(state, cache.AxisSegments[i], snap); if (i == 0) { firstAxisSnap = snap; PublishRateSnapshot(state, snap); } } // The device answered this tick. Stamped after the axis pass rather than at the top // of the loop: EnsureConnectedAsync can hand back a cached handle without any wire // traffic, so it alone does not prove the CNC is still there. state.LastFixedPollUtc = DateTime.UtcNow; // 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 (Exception ex) { /* transient — next tick retries */ _logger.LogDebug(ex, "FOCAS servo-loads poll failed for {Host}", state.Options.HostAddress); } } if (cache.Capabilities.SpindleLoad || cache.Capabilities.SpindleMotorSpeed) { try { await PollSpindleMetersAsync(state, client, ct).ConfigureAwait(false); } catch (Exception ex) { /* transient */ _logger.LogDebug(ex, "FOCAS spindle-meter poll failed for {Host}", state.Options.HostAddress); } } // 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 (Exception ex) { /* transient — next tick retries */ _logger.LogDebug(ex, "FOCAS program-info poll failed for {Host}", state.Options.HostAddress); } programPollDue = DateTime.UtcNow + programInterval; } // Status flags (cnc_statinfo, #257) — same cadence as program/mode: run/emergency/alarm // transitions are operator-timescale events and one call returns all of them. if (cache.Capabilities.Status && programInterval > TimeSpan.Zero && DateTime.UtcNow >= statusPollDue) { try { state.LastStatus = await client.GetStatusAsync(ct).ConfigureAwait(false); } catch (Exception ex) { /* transient — keep the last value; staleness is dated by LastFixedPollUtc */ _logger.LogDebug(ex, "FOCAS status poll failed for {Host}", state.Options.HostAddress); } statusPollDue = 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 (Exception ex) { /* per-kind failures are non-fatal */ _logger.LogDebug(ex, "FOCAS timer poll failed for {Host} kind={Kind}", state.Options.HostAddress, kind); } } timerPollDue = DateTime.UtcNow + timerInterval; } } catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; } catch (Exception ex) { /* next tick retries — transient blips are expected */ _logger.LogDebug(ex, "FOCAS fixed-tree poll tick failed for {Host}", state.Options.HostAddress); } 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, string axisSegment, FocasDynamicSnapshot snap) { var host = state.Options.HostAddress; // Each cnc_rddynamic2 position block carries its OWN scale (raw / base^exponent — see // FocasPosition), so positions surface in engineering units with no second read; the // configured PositionDecimalPlaces applies only to a block that carried no scale. A block // describing no usable position (vacant 0xFFFF marker, odd base, absurd exponent) is // cached as NaN and read back as BadNoData for that node alone (see NoValue). FeedRate / // SpindleSpeed (rate snapshot) and ServoLoad are NOT position-scaled (published elsewhere). var dp = state.Options.PositionDecimalPlaces; state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axisSegment}/AbsolutePosition")] = snap.AbsolutePosition.ToEngineering(dp) ?? NoValue; state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axisSegment}/MachinePosition")] = snap.MachinePosition.ToEngineering(dp) ?? NoValue; state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axisSegment}/RelativePosition")] = snap.RelativePosition.ToEngineering(dp) ?? NoValue; state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axisSegment}/DistanceToGo")] = snap.DistanceToGo.ToEngineering(dp) ?? NoValue; } /// /// Cache sentinel for "the CNC answered, but this field holds no usable value" (an /// undescribable position / meter record). The read path turns it into /// for that node only — never a Good number. /// private const double NoValue = double.NaN; /// Builds the snapshot for one cached double, mapping to BadNoData. private static DataValueSnapshot CachedDouble(double value, uint status, DateTime sourceUtc, DateTime now) => double.IsNaN(value) ? new DataValueSnapshot(null, status == FocasStatusMapper.Good ? FocasStatusMapper.BadNoData : status, sourceUtc, now) : new DataValueSnapshot(value, status, sourceUtc, now); 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 path segment. Stored separately from /// LastFixedSnapshots (which is keyed by full-reference) because the /// servo-load read path resolves by axis segment. /// /// /// The device-reported load names run through the same disambiguation as the axis list /// (#548): cnc_rdsvmeter answers per-axis in slot order, so the k-th load named /// AA belongs to the k-th axis named AA and lands under the same /// AA_k segment the emitter published — without this, two same-named axes /// collapsed onto one dictionary entry and the second overwrote the first. /// private static void PublishServoLoads(DeviceState state, IReadOnlyList loads) { var segments = FixedTreeNames.Disambiguate(loads.Select(l => l.AxisName)); for (var i = 0; i < loads.Count; i++) state.LastServoLoads[segments[i]] = loads[i].LoadPercent ?? NoValue; } /// /// Read and cache both spindle meters (load, motor speed) — each a cnc_rdspmeter /// percentage of full scale (16384 = 100%) — for every spindle slot, as capability allows. /// A slot whose record describes no value is cached as (BadNoData). /// private static async Task PollSpindleMetersAsync(DeviceState state, IFocasClient client, CancellationToken ct) { var caps = state.FixedTreeCache?.Capabilities; if (caps is { SpindleLoad: true }) { var loads = await client.GetSpindleLoadsAsync(ct).ConfigureAwait(false); for (var i = 0; i < loads.Count; i++) state.LastSpindleLoads[i] = loads[i] ?? NoValue; } if (caps is { SpindleMotorSpeed: true }) { var speeds = await client.GetSpindleMotorSpeedsAsync(ct).ConfigureAwait(false); for (var i = 0; i < speeds.Count; i++) state.LastSpindleMotorSpeeds[i] = speeds[i] ?? NoValue; } } private static readonly string[] StatusFields = ["Auto", "Run", "Motion", "Mstb", "Emergency", "Alarm", "Edit", "TmMode"]; 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 (TryReadFixedTreeOnDevice(state, reference[(state.Options.HostAddress.Length + 1)..], now) is { } hit) return hit; } return null; } /// /// How long after a device's last answered fixed-tree poll its cached values stop being /// served as Good. Eight axis-poll cadences, floored at 30 s so the sub-second default /// (250 ms) does not make an ordinarily slow CNC flap. /// /// The configured fixed-tree poll interval. /// The staleness budget. internal static TimeSpan FixedTreeStaleAfter(TimeSpan pollInterval) => TimeSpan.FromTicks(Math.Max(pollInterval.Ticks * 8, TimeSpan.FromSeconds(30).Ticks)); /// /// Resolves one fixed-tree (Identity/SeriesNumber, /// Axes/X/AbsolutePosition, …) against a single device's caches. Returns null when /// this device's fixed tree has no such path — the caller decides what that means. /// /// The device whose caches to read. /// The fixed-tree path, with no device prefix. /// The read timestamp. /// The snapshot, or null when the path names nothing on this device. /// /// Values are dated from , not from /// , and go BadCommunicationError once that stamp is older than /// . A cache hit deliberately does NOT advance driver health: /// the value came from the poll loop, not from this call, and crediting it would let a driver /// serving nothing but fixed-tree tags report a fresh successful read while its CNC was gone. /// private DataValueSnapshot? TryReadFixedTreeOnDevice(DeviceState state, string path, DateTime now) { var polledUtc = state.LastFixedPollUtc; if (polledUtc is null) return null; var status = now - polledUtc.Value > FixedTreeStaleAfter(_options.FixedTree.PollInterval) ? FocasStatusMapper.BadCommunicationError : FocasStatusMapper.Good; var sourceUtc = polledUtc.Value; if (state.LastFixedSnapshots.TryGetValue( FixedTreeReference(state.Options.HostAddress, path), out var raw)) return CachedDouble(raw, status, sourceUtc, now); // Servo-load match: path shape is "Axes/{name}/ServoLoad" var suffixFull = path; 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 CachedDouble(load, status, sourceUtc, now); } // Spindle matches: "Spindle/{name}/Load" + "Spindle/{name}/MotorSpeedPct" 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.SpindleSegments.Count; i++) { // Match on the disambiguated segment the emitter published (#548) — matching // raw Display would resolve every duplicate name to the first spindle. if (string.Equals(spindleCache.SpindleSegments[i], spindleName, StringComparison.OrdinalIgnoreCase)) { idx = i; break; } } if (idx >= 0) { var meters = field switch { "Load" => state.LastSpindleLoads, "MotorSpeedPct" => state.LastSpindleMotorSpeeds, _ => null, }; if (meters is not null && meters.TryGetValue(idx, out var pct)) return CachedDouble(pct, status, sourceUtc, now); } } } // Identity strings + program / op-mode fields aren't cached as doubles — // re-derive from the struct caches. if (state.FixedTreeCache is { } cache) { var value = path 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?.Mode is { } mode ? FocasOpMode.ToText(mode) : null, "Status/Auto" => state.LastStatus?.Auto, "Status/Run" => state.LastStatus?.Run, "Status/Motion" => state.LastStatus?.Motion, "Status/Mstb" => state.LastStatus?.Mstb, "Status/Emergency" => state.LastStatus?.Emergency, "Status/Alarm" => state.LastStatus?.Alarm, "Status/Edit" => state.LastStatus?.Edit, "Status/TmMode" => state.LastStatus?.TmMode, "Status/RunText" => state.LastStatus is { } st ? FocasRunState.ToText(st.Run) : 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, status, sourceUtc, now); // The program bundle has been read, but the CNC did not report this field (a refused // sub-read, or no O-number in the payload). That is Bad — never a fabricated 0, and // not "no such node" either. if (state.LastProgramInfo is not null && path is "Program/Name" or "Program/ONumber" or "Program/BlockCount" or "OperationMode/Mode" or "OperationMode/ModeText") return new DataValueSnapshot( null, status == FocasStatusMapper.Good ? FocasStatusMapper.BadNoData : status, sourceUtc, 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 (Exception ex) { /* already disposed or race — next EnsureConnected recovers */ _logger.LogDebug(ex, "FOCAS handle-recycle dispose failed for {Host}", state.Options.HostAddress); } } } 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 ---- /// /// /// are the RawPaths of the alarm-bearing raw tags (the host's alarm /// subscription set), and each becomes ONE Part 9 condition whose ConditionId is that RawPath (Gitea #647). /// Never throws for a disabled flag: authoring an ALARM: tag is the opt-in, and a RawPath that is /// not an ALARM: tag of this driver is simply never raised. /// public Task SubscribeAlarmsAsync( IReadOnlyList sourceNodeIds, CancellationToken cancellationToken) => _alarmProjection.SubscribeAsync(sourceNodeIds); /// public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) => _alarmProjection.UnsubscribeAsync(handle); /// /// Read-only: nothing is written to the CNC. See . public Task AcknowledgeAsync( IReadOnlyList acknowledgements, CancellationToken cancellationToken) => FocasAlarmProjection.AcknowledgeAsync(acknowledgements); /// Gets the attached alarm handlers, or when nobody is listening. internal EventHandler? AlarmHandlers => OnAlarmEvent; /// Gets the authored ALARM: tags (RawPath → tag). Test seam + projection input. internal IReadOnlyDictionary AlarmTags => Volatile.Read(ref _alarmTags); /// Gets the alarm projection. Test seam. internal FocasAlarmProjection AlarmProjection => _alarmProjection; /// Gets the alarm poll cadence — one cnc_rdalmmsg2 per alarm-owning device per tick. internal TimeSpan AlarmPollInterval => _options.AlarmProjection.PollInterval; /// /// The ALARM-path half of driver health (Gitea #647 re-review R3): device host → why its alarm read is failing /// at the TRANSPORT. Kept apart from (the value path) and combined with it in /// , so neither path can overwrite the other: a good value read cannot mask a failing /// alarm read (and flip /hosts back and forth every poll), and a good alarm read cannot mask a value-path /// Degraded. A CNC refusal never lands here (R2 — see ). /// private readonly ConcurrentDictionary _alarmTransportFailures = new(StringComparer.OrdinalIgnoreCase); /// UTC ticks of the last successful alarm read (0 = none) — the alarm path's LastSuccessfulRead. private long _alarmLastSuccessTicks; /// /// A successful alarm read of : clears that device's alarm-path failure and /// advances the alarm path's LastSuccessfulRead, so an alarm-only driver's health tracks its CNC. /// /// The device read. internal void NoteAlarmReadSucceeded(string hostAddress) { _alarmTransportFailures.TryRemove(hostAddress, out _); Interlocked.Exchange(ref _alarmLastSuccessTicks, DateTime.UtcNow.Ticks); } /// /// The CNC REFUSED the alarm read (R2): the transport is fine, so any alarm-path transport failure of the /// device is over — but nothing was read, so LastSuccessfulRead does not advance. /// /// The device that answered. internal void NoteAlarmReadRefused(string hostAddress) => _alarmTransportFailures.TryRemove(hostAddress, out _); /// /// A TRANSPORT failure of an alarm read: the alarm path is Degraded until the device's next good read, and its /// LastSuccessfulRead stays frozen — the conjunction the comms-loss watchdog (#527) trips on, which a driver /// whose only tags are ALARM: tags otherwise never reached (Gitea #647 review). /// /// The device whose alarm read failed. /// Why. internal void NoteAlarmReadFailed(string hostAddress, Exception error) => _alarmTransportFailures[hostAddress] = $"FOCAS alarm read failed for {hostAddress}: {error.Message}"; /// Clears the alarm-path health (a fresh device set at initialise / shutdown). private void ResetAlarmHealth() { _alarmTransportFailures.Clear(); Interlocked.Exchange(ref _alarmLastSuccessTicks, 0); } /// /// Reads one device's active-alarm list. Throws on any failure — an unknown device, a connect that /// fails, a read the CNC refuses — because the projection must never mistake a failed read for an empty /// list (Gitea #647). /// /// The device host address. /// Cancellation token. /// The device's active alarms. internal async Task> ReadDeviceAlarmsAsync(string hostAddress, CancellationToken ct) { if (!_devices.TryGetValue(hostAddress, out var state)) throw new InvalidOperationException($"FOCAS device '{hostAddress}' is not configured."); var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false); return await client.ReadAlarmsAsync(ct).ConfigureAwait(false); } // ---- IPerCallHostResolver ---- /// /// Resolve a reference to the device host that keys its per-host resilience /// (bulkhead / circuit breaker). CONV-4: routes through /// so an equipment-tag reference (raw TagConfig /// JSON) resolves to its OWN authored device rather than the first-device fallback. The /// empty-host guard covers FOCAS's ?? "" default so an address-less equipment tag /// falls back rather than keying an empty host. The resolver caches parses, so this adds no /// per-call cost after first use. /// /// The tag reference (authored name or equipment-tag JSON). /// The device host key. public string ResolveHost(string fullReference) { if (_resolver.TryResolve(fullReference, out var def) && !string.IsNullOrEmpty(def.DeviceHostAddress)) return def.DeviceHostAddress; return _options.Devices.FirstOrDefault()?.HostAddress ?? DriverInstanceId; } /// /// Thrown when a per-device window is still open (05/STAB-8) /// — a fail-fast that costs no wire traffic. Read/write map it to BadCommunicationError /// via their generic catch, exactly as a real connect failure. /// private sealed class ConnectionThrottledException(string message) : Exception(message); private async Task EnsureConnectedAsync( DeviceState device, CancellationToken ct, bool bypassBackoff = false) { if (device.Client is { IsConnected: true } c) return c; // Serialize the connect (hw2 Makino live gate, 2026-10-08). The alarm poll loop and the fixed-tree loop // both reach here right after init; unserialized, the second caller saw the first caller's still- // CONNECTING client as "not connected", disposed it and opened a second session, and the first caller's // failure path then disposed the second caller's client — two sessions to a CNC with a small handle pool, // a disposed client handed back, and a spurious backoff failure (observed live as the fixed-tree // bootstrap failing with ObjectDisposedException: FocasWireClient). await device.ConnectGate.WaitAsync(ct).ConfigureAwait(false); try { // Another caller may have connected while this one waited. if (device.Client is { IsConnected: true } connected) return connected; // 05/STAB-8: inside an open backoff window a data-path / per-tick-loop caller fails fast — no // socket create, no wire connect. The probe loop bypasses this and drives failure/success. if (!bypassBackoff && !device.Backoff.ShouldAttempt(DateTime.UtcNow)) throw new ConnectionThrottledException( $"FOCAS connect to {device.Options.HostAddress} throttled — backoff window open after a recent connect failure."); // Discard the existing client (if any) before creating a new one. A client that is // non-null but not connected may have been disposed by a HandleRecycle race or a prior // teardown — retrying ConnectAsync on a disposed FocasWireClient hits ThrowIfDisposed and // returns a permanent BadCommunicationError with no recovery. Replacing it unconditionally // ensures EnsureConnectedAsync always works with a fresh, non-disposed instance. if (device.Client is not null) { device.Client.Dispose(); device.Client = null; } // Wrap the raw wire client so every operation on the device's single FOCAS/2 socket is // serialized (request→response on one socket cannot interleave) and time-bounded. Without // this, the equipment poll, fixed-tree loop, probe, and recycle loop collide on the shared // socket and a stalled read blocks forever — leaving bound tags at BadWaitingForInitialData. var client = new SynchronizedFocasClient(_clientFactory.Create(), _options.Timeout); device.Client = client; try { await client.ConnectAsync(device.ParsedAddress, _options.Timeout, ct).ConfigureAwait(false); } catch (Exception ex) { // Dispose only OUR client — never one another path installed meanwhile (recycle/teardown). client.Dispose(); if (ReferenceEquals(device.Client, client)) device.Client = null; // The caller's OWN cancellation says nothing about the CNC: do not open the backoff window, or // every caller queued on ConnectGate behind it would be throttled for ~1 s for nothing. if (!(ex is OperationCanceledException && ct.IsCancellationRequested)) device.Backoff.RecordFailure(DateTime.UtcNow); // 05/STAB-8: open the backoff window throw; } device.Backoff.RecordSuccess(); // 05/STAB-8: recovery resets the window immediately return client; } finally { device.ConnectGate.Release(); } } /// Disposes the driver and releases all resources synchronously. public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult(); /// Disposes the driver and releases all resources asynchronously. /// A task that represents the asynchronous operation. 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, FocasFixedTreeCapabilities Capabilities) { /// /// Unique Axes/{segment} path segments, one per axis in slot order — bare /// display names when unique, occurrence-suffixed (AA_1, AA_2) when a /// CNC reports duplicate sibling axis names (#548). The emitter, the poll loop's /// snapshot keys and the read path all use THIS, never Display directly, so /// they cannot disagree. /// public IReadOnlyList AxisSegments { get; } = FixedTreeNames.Disambiguate(Axes.Select(a => a.Display)); /// /// Unique Spindle/{segment} path segments, one per spindle in slot order. The /// nameless-spindle fallback (S{n}) is applied BEFORE disambiguation so a /// fallback name colliding with a real one is disambiguated like any other duplicate. /// public IReadOnlyList SpindleSegments { get; } = FixedTreeNames.Disambiguate(Spindles.Select( (s, i) => string.IsNullOrEmpty(s.Display) ? $"S{i + 1}" : s.Display)); } /// /// 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_rdspmeter simply /// doesn't get a Spindle/{name}/MotorSpeedPct node (instead of surfacing /// BadDeviceFailure on every read). /// internal sealed record FocasFixedTreeCapabilities( bool Spindles, // cnc_rdspdlname returned 1+ spindle names bool SpindleLoad, // cnc_rdspmeter type 0 (load) bootstrap probe succeeded bool SpindleMotorSpeed, // cnc_rdspmeter type 1 (motor speed) 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 bool Status = false); // cnc_statinfo answered at bootstrap (#257) internal sealed class DeviceState(FocasHostAddress parsedAddress, FocasDeviceOptions options) { /// Gets the parsed host address. public FocasHostAddress ParsedAddress { get; } = parsedAddress; /// Gets the device configuration options. public FocasDeviceOptions Options { get; } = options; /// Gets or sets the FOCAS client instance. public IFocasClient? Client { get; set; } /// /// Per-device connect-attempt throttle (05/STAB-8). A dead CNC fails fast inside the /// backoff window instead of paying a full two-socket reconnect on every tick of each of /// the read / write / probe / fixed-tree / recycle loops; the probe loop bypasses it and a /// successful connect resets it. The connect itself is serialized by /// ; the backoff is consulted inside it. /// public ConnectionBackoff Backoff { get; } = new(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(30)); /// /// Serializes EnsureConnectedAsync per device, so concurrent loops (alarm poll, fixed tree, data /// path) share ONE connect instead of racing two sessions and disposing each other's client. /// public SemaphoreSlim ConnectGate { get; } = new(1, 1); /// Gets the lock object for probe synchronization. public object ProbeLock { get; } = new(); /// Gets or sets the current host connectivity state. public HostState HostState { get; set; } = HostState.Unknown; /// Gets or sets the timestamp when host state last changed. public DateTime HostStateChangedUtc { get; set; } = DateTime.UtcNow; /// Gets or sets the cancellation token source for the probe loop. public CancellationTokenSource? ProbeCts { get; set; } /// Gets or sets the cancellation token source for the recycle loop. public CancellationTokenSource? RecycleCts { get; set; } /// Gets or sets the cancellation token source for the fixed-tree loop. public CancellationTokenSource? FixedTreeCts { get; set; } /// The probe loop task, retained so shutdown can join it (STAB-23). public Task? ProbeTask { get; set; } /// The recycle loop task, retained so shutdown can join it (STAB-23). public Task? RecycleTask { get; set; } /// The fixed-tree loop task, retained so shutdown can join it (STAB-23). public Task? FixedTreeTask { get; set; } /// Gets or sets the fixed-tree cache for this device. public FocasFixedTreeCache? FixedTreeCache { get; set; } /// /// Gets or sets the UTC time the fixed-tree loop last got an answer out of this device /// — set when the bootstrap completes and again after each poll tick's axis pass. Null /// until the device has answered once. /// /// /// This is what stops a dead CNC publishing its last values as Good forever. The /// fixed-tree poll loop swallows a failed tick (LogDebug + retry) by design, so a device /// that stops answering leaves the caches populated and unchanging with nothing marking /// them stale. That was harmless while nothing could bind to the fixed tree; Gitea #540 /// makes those nodes authorable, so reads are now dated from this stamp and go Bad once /// it falls outside . /// public DateTime? LastFixedPollUtc { get; set; } /// /// Gets the last fixed-tree snapshots by field name. Double-typed so axis /// positions can carry the engineering-unit scale applied at /// ; integer fields (feed rate, spindle speed) widen /// to double on store. (NaN) marks a field the CNC described as /// holding no value. /// public ConcurrentDictionary LastFixedSnapshots { get; } = new(StringComparer.OrdinalIgnoreCase); /// /// PMC ranges () the CNC rejected as a merged read, so later polls skip /// straight to per-tag reads instead of paying a doomed round-trip every cycle (#266). /// public ConcurrentDictionary CoalesceRejected { get; } = new(StringComparer.Ordinal); /// Gets or sets the last cnc_statinfo snapshot (#257); null until the first answer. public FocasStatus? LastStatus { get; set; } /// Gets or sets the last program information snapshot. public FocasProgramInfo? LastProgramInfo { get; set; } /// Gets or sets the cached first-axis dynamic snapshot — feeds Program/Number, /MainNumber, /Sequence. public FocasDynamicSnapshot? LastProgramAxisRef { get; set; } /// Gets the last timer values by timer kind. public ConcurrentDictionary LastTimers { get; } = new(); /// Gets the last servo load percentages by servo name. public ConcurrentDictionary LastServoLoads { get; } = new(StringComparer.OrdinalIgnoreCase); /// /// Gets the last spindle load percentages (% of rated load) by spindle index; /// for a slot the CNC described as holding none. /// public ConcurrentDictionary LastSpindleLoads { get; } = new(); /// /// Gets the last spindle motor-speed percentages (% of maximum speed) by spindle index; /// for a slot the CNC described as holding none. /// public ConcurrentDictionary LastSpindleMotorSpeeds { get; } = new(); /// Disposes the FOCAS client instance. public void DisposeClient() { Client?.Dispose(); Client = null; } } }