Files
lmxopcua/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
T
Joseph Doherty b61fe38823 fix(focas): browse-committed fixed-tree tags are readable (Gitea #540)
Authoring FOCAS tags through /raw -> Browse device... produced tags the driver
could never read. Every committed tag read BadNodeIdUnknown forever, and the
failure was silent end to end: the AdminUI accepted it, the deploy gate sealed
green, and the driver reported Healthy with 0 errors while ignoring the tags.
Net effect: the whole fixed-tree surface could not be published through any v3
authoring path. Found cutting wonder-app-vd03 over to v3 against a real Makino
Pro 5 / Fanuc 30i, where 0 of 20 committed tags read while two hand-authored
MACRO: tags on the same device read Good.

A captured browse leaf's id IS its DriverAttributeInfo.FullName, and the fixed
tree emitted "{deviceHost}/{path}" there -- an internal cache key, not an
address. Both authoring paths wrote it verbatim into TagConfig.address, and
FocasAddress.TryParse accepts no such form, so the driver skipped the tags at
init.

Fixed at the emitter rather than by stripping the host at commit, which fixes
the browse-commit flow and the manual picker's tree-select together and leaves
one canonical address form:

- FocasAddress gains a fourth kind, FIXED:<path>, and moves to Contracts so the
  AdminUI and ControlPlane can parse without referencing the runtime driver
  (#526). A FIXED: read is served from the fixed-tree snapshot the poll loop
  already maintains -- no area read, no connection. Writes refuse: nothing in
  that tree is a writable location on the CNC.
- The address carries no device. A tag's device comes from its Device row, and
  a second routing channel for one fact is what created this defect.
- An authored FIXED: tag arms the fixed-tree poll loop by itself. Enabled
  defaults false and the browser force-enables it only for a browse, so the
  operator would otherwise browse, commit, deploy, and land on a driver whose
  loop never starts -- every tag Bad with a correct address.
- Fixed-tree values are now dated from the last answered poll and go
  BadCommunicationError once it is stale (8 cadences, floored at 30s). The poll
  loop swallows a failed tick by design, so a dead CNC would otherwise serve its
  last snapshot as Good indefinitely -- harmless while nothing could bind to the
  tree, not harmless now.

Guardrails, so this class cannot recur silently:

- FocasTagConfigModel.Validate() runs the real parser instead of a non-empty
  check, and FocasTagDefinitionFactory.Inspect() warns at the deploy gate, which
  covers CSV/API config that never touches the modal.
- The manual picker offered axis/spindle/program/status "parameter groups" --
  none of which is a FOCAS addressing space -- and its own default value,
  axis:5, was unusable. It now composes PMC / PARAM: / MACRO: through
  FocasAddress, so what it emits is parseable by construction.

Tests assert addresses ROUND-TRIP through the parser rather than matching an
expected string: the previous FocasAddressBuilderTests pinned "axis:5" exactly,
which is how a green suite coexisted with a picker nobody could use. Verified
red-first against the old emitter. FOCAS 308, AdminUI 1095, ControlPlane 169,
Core 581 -- all pass, 0 warnings.

The remaining half of #540 -- a driver reporting Healthy/0 errors while
skipping tags at init -- is fleet-wide (eight drivers do this) and is filed
separately rather than patched per-driver.
2026-08-05 17:32:18 -04:00

1548 lines
83 KiB
C#

using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// <summary>
/// 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
/// <see cref="IFocasClient"/> the deployment supplies — FWLIB itself is Fanuc-proprietary
/// and cannot be redistributed.
/// </summary>
/// <remarks>
/// PR 1 ships <see cref="IDriver"/> only; read / write / discover / subscribe / probe / host-
/// resolver capabilities land in PRs 2 and 3. The <see cref="IFocasClient"/> abstraction
/// shipped here lets PR 2 onward stay license-clean — all tests run against a fake client
/// + the default <see cref="UnimplementedFocasClientFactory"/> makes misconfigured servers
/// fail fast.
/// </remarks>
public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, IDisposable, IAsyncDisposable
{
/// <summary>Mutable so <see cref="InitializeAsync"/> can adopt a re-parsed config (#516). Written only
/// on the init path, together with <see cref="_clientFactory"/>, before either is used.</summary>
private FocasDriverOptions _options;
private readonly string _driverInstanceId;
/// <summary>Mutable for the same reason as <see cref="_options"/> — the <c>Backend</c> config key
/// selects it, so the two must move together or a new tag set polls through an old backend.</summary>
private IFocasClientFactory _clientFactory;
/// <summary>Re-parses a <c>DriverConfig</c> 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.</summary>
private readonly Func<string, FocasDriverBinding>? _rebind;
private readonly PollGroupEngine _poll;
private readonly ILogger<FocasDriver> _logger;
private readonly Dictionary<string, DeviceState> _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).
private readonly Dictionary<string, FocasTagDefinition> _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<FocasTagDefinition> _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 readonly ConcurrentDictionary<string, FocasAddress> _parsedAddressesByTagName =
new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Whether this driver instance runs its per-device fixed-tree poll loop, and therefore
/// serves fixed-tree values. Computed at <see cref="InitializeAsync"/> from the config flag
/// OR the presence of at least one authored <see cref="FocasAreaKind.Fixed"/> tag.
/// </summary>
/// <remarks>
/// <b>The OR is load-bearing.</b> <c>FocasFixedTreeOptions.Enabled</c> defaults to
/// <see langword="false"/> 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 <c>FIXED:</c> tag IS the request for the tree; the config flag stays
/// as the way to publish it without authoring anything.
/// </remarks>
private bool _fixedTreeActive;
private FocasAlarmProjection? _alarmProjection;
// _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);
/// <summary>Occurs when data changes on a subscribed tag.</summary>
public event EventHandler<DataChangeEventArgs>? OnDataChange;
/// <summary>Occurs when a device host connection status changes.</summary>
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
/// <summary>Occurs when an alarm event is raised.</summary>
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
/// <summary>Initializes a new instance of the <see cref="FocasDriver"/> class with the provided options and dependencies.</summary>
/// <param name="options">The driver configuration options.</param>
/// <param name="driverInstanceId">The unique identifier for this driver instance.</param>
/// <param name="clientFactory">Optional factory for creating FOCAS client instances.</param>
/// <param name="logger">Optional logger instance.</param>
/// <param name="rebind">Optional re-parse of a <c>DriverConfig</c> into every config-derived dependency,
/// supplied by the factory so <see cref="InitializeAsync"/> 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.</param>
public FocasDriver(FocasDriverOptions options, string driverInstanceId,
IFocasClientFactory? clientFactory = null,
ILogger<FocasDriver>? logger = null,
Func<string, FocasDriverBinding>? rebind = null)
{
ArgumentNullException.ThrowIfNull(options);
_options = options;
_driverInstanceId = driverInstanceId;
_clientFactory = clientFactory ?? new Wire.WireFocasClientFactory();
_rebind = rebind;
_logger = logger ?? NullLogger<FocasDriver>.Instance;
_resolver = new EquipmentTagRefResolver<FocasTagDefinition>(
r => _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);
}
/// <summary>True when the supplied DriverConfig JSON carries a real body. The bootstrapper always passes
/// a populated document; some unit tests pass <c>"{}"</c> or an empty string to exercise lifecycle shape
/// without a config — those keep the constructor-supplied options + backend.</summary>
private static bool HasConfigBody(string? driverConfigJson)
{
if (string.IsNullOrWhiteSpace(driverConfigJson)) return false;
var trimmed = driverConfigJson.Trim();
return trimmed is not "{}" and not "[]";
}
/// <summary>Upper bound on the poll-loop failure backoff — adopts the S7-proven 30 s cap fleet-wide (05/STAB-8).</summary>
private static readonly TimeSpan PollBackoffCap = TimeSpan.FromSeconds(30);
/// <summary>
/// 05/STAB-9 — routes a poll-loop reader failure to the driver health surface: logs it and
/// degrades to <see cref="DriverState.Degraded"/> preserving <c>LastSuccessfulRead</c>.
/// Never downgrades a <see cref="DriverState.Faulted"/> state (a stronger, config-level signal).
/// </summary>
/// <param name="ex">The exception caught by the poll engine.</param>
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));
}
/// <inheritdoc />
public string DriverInstanceId => _driverInstanceId;
/// <inheritdoc />
public string DriverType => "FOCAS";
/// <summary>
/// Opens the configured CNC handles and builds the authored tag table.
/// <para><paramref name="driverConfigJson"/> <b>is</b> re-parsed here when the driver was built by
/// the factory (Gitea #516), and the re-parse is <b>all-or-nothing</b>: the typed options and the
/// <c>IFocasClientFactory</c> the <c>Backend</c> key selects come from one <c>ParseBinding</c> 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.</para>
/// <para>A driver constructed directly (every unit test, or a caller supplying its own backend) gets
/// no rebinder and keeps its constructor-supplied pair. <c>DriverSpawnPlanner</c>'s stop + respawn
/// remains the outer guarantee.</para>
/// </summary>
/// <param name="driverConfigJson">The driver configuration JSON (see remarks — not re-parsed).</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A completed task.</returns>
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
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).
foreach (var entry in _options.RawTags)
{
if (!FocasTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def))
{
_logger.LogWarning(
"FOCAS tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
_driverInstanceId, entry.RawPath);
continue;
}
// TODO(v3 WaveC): the live device host must come from the tag's Device row's DeviceConfig
// (keyed by the RawPath's device segment). Until Wave C threads DeviceConfig through, recover
// the host by matching the entry's DeviceName against the configured Devices 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)
{
_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)
{
_logger.LogWarning(
"FOCAS tag '{RawPath}' ({Address}) rejected by capability matrix: {Reason}; skipping. Driver={DriverInstanceId}",
entry.RawPath, def.Address, reason, _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.
_parsedAddressesByTagName[entry.RawPath] = parsed;
}
if (_options.Probe.Enabled)
{
foreach (var state in _devices.Values)
{
state.ProbeCts = new CancellationTokenSource();
var ct = state.ProbeCts.Token;
_ = Task.Run(() => ProbeLoopAsync(state, ct), ct);
}
}
if (_options.HandleRecycle.Enabled)
{
foreach (var state in _devices.Values)
{
state.RecycleCts = new CancellationTokenSource();
var ct = state.RecycleCts.Token;
_ = Task.Run(() => RecycleLoopAsync(state, ct), ct);
}
}
if (_options.AlarmProjection.Enabled)
_alarmProjection = new FocasAlarmProjection(this, _options.AlarmProjection.PollInterval, _logger);
// 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;
_ = Task.Run(() => FixedTreeLoopAsync(state, ct), ct);
}
}
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;
}
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
await _poll.DisposeAsync().ConfigureAwait(false);
if (_alarmProjection is { } proj)
{
await proj.DisposeAsync().ConfigureAwait(false);
_alarmProjection = null;
}
foreach (var state in _devices.Values)
{
// 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); }
state.ProbeCts?.Dispose();
state.ProbeCts = null;
try { state.RecycleCts?.Cancel(); }
catch (Exception ex) { _logger.LogDebug(ex, "Cancelling recycle CTS for {Host} failed", state.Options.HostAddress); }
state.RecycleCts?.Dispose();
state.RecycleCts = null;
try { state.FixedTreeCts?.Cancel(); }
catch (Exception ex) { _logger.LogDebug(ex, "Cancelling fixed-tree CTS for {Host} failed", state.Options.HostAddress); }
state.FixedTreeCts?.Dispose();
state.FixedTreeCts = null;
state.DisposeClient();
}
_devices.Clear();
_tagsByRawPath.Clear();
_resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
_parsedAddressesByTagName.Clear();
_fixedTreeActive = false;
Volatile.Write(ref _health, new DriverHealth(DriverState.Unknown, Volatile.Read(ref _health).LastSuccessfulRead, null));
}
/// <inheritdoc />
public DriverHealth GetHealth() => Volatile.Read(ref _health);
/// <summary>Gets the number of configured devices.</summary>
internal int DeviceCount => _devices.Count;
/// <summary>Gets the state of a device by host address.</summary>
/// <param name="hostAddress">The host address of the device.</param>
/// <returns>The device state if found; otherwise null.</returns>
internal DeviceState? GetDeviceState(string hostAddress) =>
_devices.TryGetValue(hostAddress, out var s) ? s : null;
/// <summary>Test seam — the resolved options the factory built this driver from.</summary>
internal FocasDriverOptions Options => _options;
/// <summary>Test seam — returns true when a parsed <see cref="FocasAddress"/> for the given
/// reference is present in the address cache (both authored and equipment-tag paths).</summary>
/// <param name="reference">The tag or equipment-tag reference to check.</param>
/// <returns><see langword="true"/> if a parsed address is cached for the reference; otherwise <see langword="false"/>.</returns>
internal bool IsParsedAddressCached(string reference) =>
_parsedAddressesByTagName.ContainsKey(reference);
// TODO(v3 WaveC): the live device host must come from the tag's Device row's DeviceConfig (keyed by
// the RawPath's device segment). Until Wave C threads DeviceConfig through, recover the host by matching
// the RawTagEntry's DeviceName against the configured Devices list (by DeviceName, falling back to
// HostAddress; and, 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 ----
/// <inheritdoc />
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(fullReferences);
var now = DateTime.UtcNow;
var results = new DataValueSnapshot[fullReferences.Count];
for (var i = 0; i < fullReferences.Count; i++)
{
var reference = fullReferences[i];
// Fixed-tree T1 — fixed-tree references are synthesized from the cached
// dynamic snapshot + sysinfo; no P/Invoke per Read since the poll loop
// already fires them on cadence.
//
// 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;
}
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;
}
// ---- IWritable ----
/// <inheritdoc />
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(writes);
var results = new WriteResult[writes.Count];
for (var i = 0; i < writes.Count; i++)
{
var w = writes[i];
if (!_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 ----
/// <inheritdoc />
// 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;
/// <summary>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.</summary>
public bool SupportsOnlineDiscovery => true;
/// <inheritdoc />
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");
foreach (var axis in cache.Axes)
{
var axisFolder = axesFolder.Folder(axis.Display, axis.Display);
EmitAxisVariable(axisFolder, axis.Display, "AbsolutePosition");
EmitAxisVariable(axisFolder, axis.Display, "MachinePosition");
EmitAxisVariable(axisFolder, axis.Display, "RelativePosition");
EmitAxisVariable(axisFolder, axis.Display, "DistanceToGo");
if (cache.Capabilities.ServoLoad)
EmitAxisVariable(axisFolder, axis.Display, "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 + MaxRpm each gated on their own capability probe.
if (cache.Capabilities.Spindles)
{
var spindleRoot = deviceFolder.Folder("Spindle", "Spindle");
for (var i = 0; i < cache.Spindles.Count; i++)
{
var s = cache.Spindles[i];
var name = string.IsNullOrEmpty(s.Display) ? $"S{i + 1}" : s.Display;
var spindleFolder = spindleRoot.Folder(name, name);
if (cache.Capabilities.SpindleLoad)
EmitFixedVariable(spindleFolder, $"Spindle/{name}", "Load", DriverDataType.Int32);
if (cache.Capabilities.SpindleMaxRpm && i < cache.SpindleMaxRpms.Count)
EmitFixedVariable(spindleFolder, $"Spindle/{name}", "MaxRpm", DriverDataType.Int32);
}
}
// Fixed-tree T2 — Program + OperationMode subtrees (gated on capability).
if (cache.Capabilities.ProgramInfo)
{
var program = deviceFolder.Folder("Program", "Program");
EmitFixedVariable(program, "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 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 }
/// <summary>
/// Emit one <c>Identity</c> leaf.
/// </summary>
/// <remarks>
/// <para>
/// <b>The emitted <c>FullName</c> is the authorable address, not the runtime cache key.</b>
/// A captured browse leaf's id IS its <c>FullName</c>, and that string is what both the
/// <c>/raw</c> browse-commit mapper and the manual picker's tree-select write into the tag's
/// <c>TagConfig.address</c>. 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 <c>BadNodeIdUnknown</c> forever, while the deploy sealed green and driver health
/// stayed Healthy (Gitea #540).
/// </para>
/// <para>
/// 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. <see cref="FixedTreeReference"/> keeps the host for the internal
/// per-device caches, which do need to distinguish devices.
/// </para>
/// </remarks>
/// <param name="folder">The address-space folder to emit into.</param>
/// <param name="field">The identity field name.</param>
/// <param name="type">The field's data type.</param>
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)));
}
/// <summary>Emit one <c>Axes</c> leaf. See <see cref="EmitIdentityVariable"/> on the address shape.</summary>
/// <param name="folder">The address-space folder to emit into.</param>
/// <param name="axisName">The axis display name.</param>
/// <param name="field">The per-axis field name.</param>
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)));
}
/// <summary>
/// The <see cref="FocasDataType"/> 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 <see cref="FocasDataType"/> member — the driver-agnostic
/// <see cref="DriverDataType"/> cannot be reversed (Byte/Int16/Int32 all collapse to
/// <see cref="DriverDataType.Int32"/>), so the native name is carried rather than derived.
/// </summary>
private static string NativeName(FocasDriverDataType type) => type switch
{
FocasDriverDataType.Int32 => nameof(FocasDataType.Int32),
FocasDriverDataType.Float64 => nameof(FocasDataType.Float64),
_ => nameof(FocasDataType.String),
};
/// <summary>
/// Emit a variable under a named fixed-tree folder (Program, OperationMode,
/// …). Address shape is <c>FIXED:{folderPath}/{field}</c> — see
/// <see cref="EmitIdentityVariable"/>.
/// </summary>
/// <param name="folder">The address-space folder to emit into.</param>
/// <param name="folderPath">The fixed-tree folder path.</param>
/// <param name="field">The field name.</param>
/// <param name="type">The field's data type.</param>
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,
})));
}
/// <summary>
/// Canonical <b>runtime cache key</b> 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 <see cref="TryReadFixedTreeOnDevice"/> looks up.
/// </summary>
/// <remarks>
/// <b>This is an internal key, not an authorable address</b> — see
/// <see cref="EmitIdentityVariable"/> for why the two are deliberately different strings
/// (Gitea #540).
/// </remarks>
/// <param name="deviceHost">The host address of the device.</param>
/// <param name="path">The path within the fixed tree.</param>
/// <returns>The canonical cache key for the node.</returns>
internal static string FixedTreeReference(string deviceHost, string path) =>
$"{deviceHost}/{path}";
// ---- ISubscribable (polling overlay via shared engine) ----
/// <inheritdoc />
public Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) =>
Task.FromResult(_poll.Subscribe(fullReferences, publishingInterval));
/// <inheritdoc />
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
{
_poll.Unsubscribe(handle);
return Task.CompletedTask;
}
// ---- IHostConnectivityProbe ----
/// <inheritdoc />
public IReadOnlyList<HostConnectivityStatus> 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; }
}
}
/// <summary>
/// Per-device fixed-tree poll loop. First tick resolves sysinfo + axis names
/// (once) so <see cref="DiscoverAsync"/> can render the subtree on its next
/// invocation; every tick thereafter fires a <c>cnc_rddynamic2</c> per axis
/// and publishes OnDataChange for the axis positions + feed rate + spindle
/// speed.
/// </summary>
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);
// Per-axis decimal-place figures (cnc_getfigure), fetched once. Auto figures win
// over the manual PositionDecimalPlaces config at the publish seam; an empty list
// (or a failed/empty figure read) makes every axis fall back to the config knob.
// Defensive: a figure-read failure must NOT fault device init — default to empty.
state.PositionFigures = await SafeProbe(() => client.GetPositionFiguresAsync(ct), []);
// Optional-API probes — each returns empty / throws when unsupported.
var spindles = await SafeProbe(() => client.GetSpindleNamesAsync(ct), []);
var spindleMaxRpms = await SafeProbe(() => client.GetSpindleMaxRpmsAsync(ct), []);
var servoLoads = await SafeProbe(() => client.GetServoLoadsAsync(ct), []);
var programInfo = await SafeTryProbe(() => client.GetProgramInfoAsync(ct));
var timer = await SafeTryProbe(() => client.GetTimerAsync(FocasTimerKind.PowerOn, ct));
var spindleLoad = await SafeProbe(() => client.GetSpindleLoadsAsync(ct), []);
var caps = new FocasFixedTreeCapabilities(
Spindles: spindles.Count > 0,
SpindleLoad: spindleLoad.Count > 0,
SpindleMaxRpm: spindleMaxRpms.Count > 0,
ServoLoad: servoLoads.Count > 0,
ProgramInfo: programInfo is not null,
Timers: timer is not null);
state.FixedTreeCache = new FocasFixedTreeCache(
sys, [.. axes], [.. spindles], [.. spindleMaxRpms], caps);
// 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-loads cache from bootstrap if supported — avoids a
// "tree is there but reads say BadNodeIdUnknown" window on startup.
if (state.FixedTreeCache?.Capabilities is { SpindleLoad: true })
{
try
{
var client2 = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
var loads = await client2.GetSpindleLoadsAsync(ct).ConfigureAwait(false);
for (var i = 0; i < loads.Count; i++) state.LastSpindleLoads[i] = loads[i];
}
catch (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;
while (!ct.IsCancellationRequested)
{
try
{
var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
var cache = state.FixedTreeCache;
if (cache is null) break;
FocasDynamicSnapshot? firstAxisSnap = null;
for (var i = 0; i < cache.Axes.Count; i++)
{
var axisIndex = i + 1; // FOCAS uses 1-based axis indexing
var axis = cache.Axes[i];
var snap = await client.ReadDynamicAsync(axisIndex, ct).ConfigureAwait(false);
PublishAxisSnapshot(state, axis, snap, i); // i = 0-based axis index → PositionFigures lookup
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)
{
try
{
var loads = await client.GetSpindleLoadsAsync(ct).ConfigureAwait(false);
for (var i = 0; i < loads.Count; i++) state.LastSpindleLoads[i] = loads[i];
}
catch (Exception ex)
{
/* transient */
_logger.LogDebug(ex, "FOCAS spindle-loads 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;
}
// 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<FocasTimerKind>())
{
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; }
}
}
/// <summary>
/// Cache a fresh axis snapshot. The poll loop doesn't fire <c>OnDataChange</c>
/// directly — subscribers go through the normal <c>SubscribeAsync</c> →
/// <see cref="PollGroupEngine"/> → <see cref="ReadAsync"/> path, which hits
/// <see cref="TryReadFixedTree"/> and returns these cached values.
/// </summary>
private static void PublishAxisSnapshot(DeviceState state, FocasAxisName axis, FocasDynamicSnapshot snap, int axisIndex)
{
var host = state.Options.HostAddress;
// cnc_rddynamic2 returns positions as scaled integers; divide by 10^figures so they
// surface in engineering units on the Float64 axis nodes. The figure is per-axis:
// an auto cnc_getfigure figure WINS, and the configured PositionDecimalPlaces is the
// fallback when the CNC didn't report one for that axis (see AxisFactor). A figure of
// 0 yields factor 1.0 — i.e. the integer widened to double, byte-identical to legacy
// behaviour (12345 / 1.0 == 12345.0). The managed WireFocasClient fetches figures live
// via ReadPositionFiguresAsync; the config knob is the per-axis fallback only. FeedRate /
// SpindleSpeed (rate snapshot) and ServoLoad are NOT position-scaled (published elsewhere).
var factor = AxisFactor(state, axisIndex);
state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axis.Display}/AbsolutePosition")] = snap.AbsolutePosition / factor;
state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axis.Display}/MachinePosition")] = snap.MachinePosition / factor;
state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axis.Display}/RelativePosition")] = snap.RelativePosition / factor;
state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axis.Display}/DistanceToGo")] = snap.DistanceToGo / factor;
}
/// <summary>
/// Resolve the position-scale factor (10^figure) for a single axis. Auto
/// (<c>cnc_getfigure</c>) wins per-axis; manual <c>PositionDecimalPlaces</c> is the
/// fallback when the CNC didn't report a figure for that axis. Both clamp non-negative;
/// 0 ⇒ factor 1.0 (legacy byte-identical). The managed wire backend fetches figures live
/// via <c>ReadPositionFiguresAsync</c>; the fallback branch fires only when the CNC
/// reports no figure for a given axis (or the figure read returns empty).
/// </summary>
private static double AxisFactor(DeviceState state, int axisIndex)
{
var figures = state.PositionFigures;
var dp = axisIndex >= 0 && axisIndex < figures.Count && figures[axisIndex] >= 0
? figures[axisIndex]
: Math.Max(0, state.Options.PositionDecimalPlaces);
return dp > 0 ? Math.Pow(10, dp) : 1.0;
}
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;
}
/// <summary>
/// Cache servo-load percentages keyed by axis name. Stored separately from
/// <c>LastFixedSnapshots</c> (which is keyed by full-reference) because the
/// servo-load read path resolves by axis name.
/// </summary>
private static void PublishServoLoads(DeviceState state, IReadOnlyList<FocasServoLoad> loads)
{
foreach (var load in loads)
state.LastServoLoads[load.AxisName] = load.LoadPercent;
}
private static object? TimerValue(DeviceState state, FocasTimerKind kind) =>
state.LastTimers.TryGetValue(kind, out var t) ? (object)t.TotalSeconds : null;
/// <summary>
/// Call an optional probe that returns a collection; swallow any exception
/// and return <paramref name="fallback"/>. Used by bootstrap to capture
/// per-series capability without letting one failed probe take down the
/// entire bootstrap sequence.
/// </summary>
private static async Task<IReadOnlyList<T>> SafeProbe<T>(
Func<Task<IReadOnlyList<T>>> probe, IReadOnlyList<T> fallback)
{
try { return await probe().ConfigureAwait(false); }
catch { return fallback; }
}
/// <summary>
/// Nullable variant — probe returns a single object or null on failure.
/// </summary>
private static async Task<T?> SafeTryProbe<T>(Func<Task<T>> probe) where T : class
{
try { return await probe().ConfigureAwait(false); }
catch { return null; }
}
/// <summary>
/// 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).
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="pollInterval">The configured fixed-tree poll interval.</param>
/// <returns>The staleness budget.</returns>
internal static TimeSpan FixedTreeStaleAfter(TimeSpan pollInterval) =>
TimeSpan.FromTicks(Math.Max(pollInterval.Ticks * 8, TimeSpan.FromSeconds(30).Ticks));
/// <summary>
/// Resolves one fixed-tree <paramref name="path"/> (<c>Identity/SeriesNumber</c>,
/// <c>Axes/X/AbsolutePosition</c>, …) against a single device's caches. Returns null when
/// this device's fixed tree has no such path — the caller decides what that means.
/// </summary>
/// <param name="state">The device whose caches to read.</param>
/// <param name="path">The fixed-tree path, with no device prefix.</param>
/// <param name="now">The read timestamp.</param>
/// <returns>The snapshot, or null when the path names nothing on this device.</returns>
/// <remarks>
/// Values are dated from <see cref="DeviceState.LastFixedPollUtc"/>, not from
/// <paramref name="now"/>, and go <c>BadCommunicationError</c> once that stamp is older than
/// <see cref="FixedTreeStaleAfter"/>. 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.
/// </remarks>
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 new DataValueSnapshot(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 new DataValueSnapshot(load, status, sourceUtc, now);
}
// Spindle matches: "Spindle/{name}/Load" + "Spindle/{name}/MaxRpm"
if (suffixFull.StartsWith("Spindle/", StringComparison.Ordinal)
&& state.FixedTreeCache is { } spindleCache)
{
var tail = suffixFull["Spindle/".Length..];
var slash = tail.IndexOf('/');
if (slash > 0)
{
var spindleName = tail[..slash];
var field = tail[(slash + 1)..];
var idx = -1;
for (var i = 0; i < spindleCache.Spindles.Count; i++)
{
var s = spindleCache.Spindles[i];
var display = string.IsNullOrEmpty(s.Display) ? $"S{i + 1}" : s.Display;
if (string.Equals(display, spindleName, StringComparison.OrdinalIgnoreCase)) { idx = i; break; }
}
if (idx >= 0)
{
object? value = field switch
{
"Load" => state.LastSpindleLoads.TryGetValue(idx, out var l) ? (object)l : null,
"MaxRpm" => idx < spindleCache.SpindleMaxRpms.Count ? (object)spindleCache.SpindleMaxRpms[idx] : null,
_ => null,
};
if (value is not null)
return new DataValueSnapshot(value, 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 is { } pi
? FocasOpMode.ToText(pi.Mode) : null,
"Timers/PowerOnSeconds" => TimerValue(state, FocasTimerKind.PowerOn),
"Timers/OperatingSeconds" => TimerValue(state, FocasTimerKind.Operating),
"Timers/CuttingSeconds" => TimerValue(state, FocasTimerKind.Cutting),
"Timers/CycleSeconds" => TimerValue(state, FocasTimerKind.Cycle),
_ => null,
};
if (value is not null)
return new DataValueSnapshot(value, 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 ----
/// <inheritdoc />
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
{
if (_alarmProjection is null)
throw new NotSupportedException(
"FOCAS alarm projection is disabled — set FocasDriverOptions.AlarmProjection.Enabled=true to opt in.");
return _alarmProjection.SubscribeAsync(sourceNodeIds, cancellationToken);
}
/// <inheritdoc />
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) =>
_alarmProjection is { } p ? p.UnsubscribeAsync(handle, cancellationToken) : Task.CompletedTask;
/// <inheritdoc />
public Task AcknowledgeAsync(
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken) =>
_alarmProjection is { } p ? p.AcknowledgeAsync(acknowledgements, cancellationToken) : Task.CompletedTask;
/// <summary>Raises an alarm event with the provided arguments.</summary>
/// <param name="args">The alarm event arguments.</param>
internal void InvokeAlarmEvent(AlarmEventArgs args) => OnAlarmEvent?.Invoke(this, args);
/// <summary>
/// Poll every configured device's active-alarm list in one pass. Used by the alarm
/// projection — kept <c>internal</c> rather than <c>public</c> because callers that
/// want alarm events should subscribe through <c>IAlarmSource</c> instead.
/// </summary>
/// <param name="deviceFilter">Optional set of device host addresses to filter results; null includes all devices.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A list of tuples containing host address and active alarms for each device.</returns>
internal async Task<IReadOnlyList<(string HostAddress, IReadOnlyList<FocasActiveAlarm> Alarms)>>
ReadActiveAlarmsAcrossDevicesAsync(HashSet<string>? deviceFilter, CancellationToken ct)
{
var result = new List<(string, IReadOnlyList<FocasActiveAlarm>)>();
foreach (var state in _devices.Values)
{
if (deviceFilter is not null && !deviceFilter.Contains(state.Options.HostAddress)) continue;
try
{
var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
var alarms = await client.ReadAlarmsAsync(ct).ConfigureAwait(false);
result.Add((state.Options.HostAddress, alarms));
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
catch (Exception ex)
{
/* surface a device-local fault on the next tick */
_logger.LogDebug(ex, "FOCAS alarm poll failed for {Host}", state.Options.HostAddress);
}
}
return result;
}
// ---- IPerCallHostResolver ----
/// <summary>
/// Resolve a reference to the device host that keys its per-host resilience
/// (bulkhead / circuit breaker). CONV-4: routes through
/// <see cref="EquipmentTagRefResolver{TDef}"/> 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 <c>?? ""</c> 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.
/// </summary>
/// <param name="fullReference">The tag reference (authored name or equipment-tag JSON).</param>
/// <returns>The device host key.</returns>
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;
}
/// <summary>
/// Thrown when a per-device <see cref="ConnectionBackoff"/> window is still open (05/STAB-8)
/// — a fail-fast that costs no wire traffic. Read/write map it to <c>BadCommunicationError</c>
/// via their generic catch, exactly as a real connect failure.
/// </summary>
private sealed class ConnectionThrottledException(string message) : Exception(message);
private async Task<IFocasClient> EnsureConnectedAsync(
DeviceState device, CancellationToken ct, bool bypassBackoff = false)
{
if (device.Client is { IsConnected: true } c) return c;
// 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.
device.Client = new SynchronizedFocasClient(_clientFactory.Create(), _options.Timeout);
try
{
await device.Client.ConnectAsync(device.ParsedAddress, _options.Timeout, ct).ConfigureAwait(false);
}
catch
{
device.Client.Dispose();
device.Client = null;
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 device.Client;
}
/// <summary>Disposes the driver and releases all resources synchronously.</summary>
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
/// <summary>Disposes the driver and releases all resources asynchronously.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
/// <summary>
/// Per-device fixed-tree cache populated once at first successful connect and
/// read-only thereafter. Used by <see cref="DiscoverAsync"/> to render the
/// tree + by <see cref="TryReadFixedTree"/> for synchronous Identity/* reads.
/// </summary>
internal sealed record FocasFixedTreeCache(
FocasSysInfo SysInfo,
IReadOnlyList<FocasAxisName> Axes,
IReadOnlyList<FocasSpindleName> Spindles,
IReadOnlyList<int> SpindleMaxRpms,
FocasFixedTreeCapabilities Capabilities);
/// <summary>
/// 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 <c>cnc_rdspmaxrpm</c> simply
/// doesn't get a <c>Spindle/{name}/MaxRpm</c> node (instead of surfacing
/// <c>BadDeviceFailure</c> on every read).
/// </summary>
internal sealed record FocasFixedTreeCapabilities(
bool Spindles, // cnc_rdspdlname returned 1+ spindle names
bool SpindleLoad, // cnc_rdspload bootstrap probe succeeded
bool SpindleMaxRpm, // cnc_rdspmaxrpm bootstrap probe succeeded
bool ServoLoad, // cnc_rdsvmeter bootstrap probe returned data
bool ProgramInfo, // cnc_exeprgname2 + cnc_rdblkcount + cnc_rdopmode work
bool Timers); // cnc_rdtimer works for at least PowerOn
internal sealed class DeviceState(FocasHostAddress parsedAddress, FocasDeviceOptions options)
{
/// <summary>Gets the parsed host address.</summary>
public FocasHostAddress ParsedAddress { get; } = parsedAddress;
/// <summary>Gets the device configuration options.</summary>
public FocasDeviceOptions Options { get; } = options;
/// <summary>Gets or sets the FOCAS client instance.</summary>
public IFocasClient? Client { get; set; }
/// <summary>
/// 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. FOCAS's <c>EnsureConnectedAsync</c> is not gate-serialized
/// (the loops already race the client swap by design), so this is best-effort — atomic
/// enough on 64-bit for the int/DateTime state, and a benign extra/skipped attempt is the
/// worst outcome of a race.
/// </summary>
public ConnectionBackoff Backoff { get; } = new(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(30));
/// <summary>Gets the lock object for probe synchronization.</summary>
public object ProbeLock { get; } = new();
/// <summary>Gets or sets the current host connectivity state.</summary>
public HostState HostState { get; set; } = HostState.Unknown;
/// <summary>Gets or sets the timestamp when host state last changed.</summary>
public DateTime HostStateChangedUtc { get; set; } = DateTime.UtcNow;
/// <summary>Gets or sets the cancellation token source for the probe loop.</summary>
public CancellationTokenSource? ProbeCts { get; set; }
/// <summary>Gets or sets the cancellation token source for the recycle loop.</summary>
public CancellationTokenSource? RecycleCts { get; set; }
/// <summary>Gets or sets the cancellation token source for the fixed-tree loop.</summary>
public CancellationTokenSource? FixedTreeCts { get; set; }
/// <summary>Gets or sets the fixed-tree cache for this device.</summary>
public FocasFixedTreeCache? FixedTreeCache { get; set; }
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// <b>This is what stops a dead CNC publishing its last values as Good forever.</b> 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 <see cref="FixedTreeStaleAfter"/>.
/// </remarks>
public DateTime? LastFixedPollUtc { get; set; }
/// <summary>
/// Gets the last fixed-tree snapshots by field name. Double-typed so axis
/// positions can carry the <c>10^PositionDecimalPlaces</c> engineering-unit
/// scale applied at <see cref="PublishAxisSnapshot"/>; integer fields
/// (feed rate, spindle speed) widen to double on store.
/// </summary>
public ConcurrentDictionary<string, double> LastFixedSnapshots { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Gets or sets the last program information snapshot.</summary>
public FocasProgramInfo? LastProgramInfo { get; set; }
/// <summary>Gets or sets the cached first-axis dynamic snapshot — feeds Program/Number, /MainNumber, /Sequence.</summary>
public FocasDynamicSnapshot? LastProgramAxisRef { get; set; }
/// <summary>Gets the last timer values by timer kind.</summary>
public ConcurrentDictionary<FocasTimerKind, FocasTimer> LastTimers { get; } = new();
/// <summary>Gets the last servo load percentages by servo name.</summary>
public ConcurrentDictionary<string, double> LastServoLoads { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Gets the last spindle load percentages by spindle index.</summary>
public ConcurrentDictionary<int, int> LastSpindleLoads { get; } = new();
/// <summary>
/// Gets or sets the per-axis position decimal-place figures fetched once at init via
/// <c>cnc_getfigure</c> (parallel to the axis-name list; index = axis). An auto figure
/// for an axis WINS over the configured <c>PositionDecimalPlaces</c>; an empty list (or
/// a failed/empty figure read) makes every axis fall back to that config knob.
/// </summary>
public IReadOnlyList<int> PositionFigures { get; set; } = [];
/// <summary>Disposes the FOCAS client instance.</summary>
public void DisposeClient()
{
Client?.Dispose();
Client = null;
}
}
}