1605 lines
87 KiB
C#
1605 lines
87 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, IBatchSubscribable,
|
|
IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, ISkippedTagReporter, IDisposable, IAsyncDisposable
|
|
{
|
|
/// <summary>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.</summary>
|
|
private readonly SkippedTagLedger _skippedTags = new();
|
|
|
|
/// <inheritdoc />
|
|
public SkippedTagReport? GetSkippedTags() => _skippedTags.Snapshot();
|
|
|
|
/// <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 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,
|
|
/// <see cref="OnDataChange"/> once per changed tag — so a consumer subscribes to EXACTLY
|
|
/// ONE of the two; subscribing both double-delivers.
|
|
/// </summary>
|
|
public event EventHandler<DataChangeBatchEventArgs>? OnDataChangeBatch;
|
|
/// <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,
|
|
onBatch: (handle, items) =>
|
|
OnDataChangeBatch?.Invoke(this, new DataChangeBatchEventArgs(handle, items)));
|
|
}
|
|
|
|
/// <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).
|
|
_skippedTags.BeginRebuild(_options.RawTags.Count);
|
|
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;
|
|
}
|
|
// 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)
|
|
{
|
|
_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;
|
|
}
|
|
_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;
|
|
}
|
|
// 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();
|
|
|
|
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");
|
|
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 + 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 name = cache.SpindleSegments[i];
|
|
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 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, 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, string axisSegment, 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/{axisSegment}/AbsolutePosition")] = snap.AbsolutePosition / factor;
|
|
state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axisSegment}/MachinePosition")] = snap.MachinePosition / factor;
|
|
state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axisSegment}/RelativePosition")] = snap.RelativePosition / factor;
|
|
state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axisSegment}/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 path segment. Stored separately from
|
|
/// <c>LastFixedSnapshots</c> (which is keyed by full-reference) because the
|
|
/// servo-load read path resolves by axis segment.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The device-reported load names run through the same disambiguation as the axis list
|
|
/// (#548): <c>cnc_rdsvmeter</c> answers per-axis in slot order, so the k-th load named
|
|
/// <c>AA</c> belongs to the k-th axis named <c>AA</c> and lands under the same
|
|
/// <c>AA_k</c> segment the emitter published — without this, two same-named axes
|
|
/// collapsed onto one dictionary entry and the second overwrote the first.
|
|
/// </remarks>
|
|
private static void PublishServoLoads(DeviceState state, IReadOnlyList<FocasServoLoad> 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;
|
|
}
|
|
|
|
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.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)
|
|
{
|
|
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>
|
|
/// Unique <c>Axes/{segment}</c> path segments, one per axis in slot order — bare
|
|
/// display names when unique, occurrence-suffixed (<c>AA_1</c>, <c>AA_2</c>) 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 <c>Display</c> directly, so
|
|
/// they cannot disagree.
|
|
/// </summary>
|
|
public IReadOnlyList<string> AxisSegments { get; } =
|
|
FixedTreeNames.Disambiguate(Axes.Select(a => a.Display));
|
|
|
|
/// <summary>
|
|
/// Unique <c>Spindle/{segment}</c> path segments, one per spindle in slot order. The
|
|
/// nameless-spindle fallback (<c>S{n}</c>) is applied BEFORE disambiguation so a
|
|
/// fallback name colliding with a real one is disambiguated like any other duplicate.
|
|
/// </summary>
|
|
public IReadOnlyList<string> SpindleSegments { get; } =
|
|
FixedTreeNames.Disambiguate(Spindles.Select(
|
|
(s, i) => string.IsNullOrEmpty(s.Display) ? $"S{i + 1}" : s.Display));
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
}
|
|
}
|