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;
}
}
}