d32d89c340
Five drivers ignored the config handed to them on reinitialize, serving the
options their constructor captured — and the deployment sealed green anyway.
Fixed on both halves, as chosen.
Seam (load-bearing): DriverSpawnPlanner routes a changed DriverConfig to
ToStop + ToSpawn instead of an in-place delta, making the factory the single
parse authority. This REVERSES the deliberate decision documented at
DriverSpawnPlan.cs:49-50 ("a pure DriverConfig change stays an in-place delta
— no reconnect"). That reasoning was correct about the resilience pipeline and
wrong about the driver. The accepted price is a reconnect per config edit.
Per-driver, and this split was not anticipated:
- Re-parse in place — Modbus, AbLegacy, OpcUaClient. ParseOptions extracted
from each factory, called from InitializeAsync behind a HasConfigBody guard
so "{}" still keeps the constructor options.
- Respawn-only, deliberately NOT re-parsed — Sql and FOCAS. Each builds more
than options from config (Sql's dialect + resolved connection string,
FOCAS's client-factory backend, both injected at construction), so adopting
new options alone would run a NEW tag set against an OLD connection. Half a
re-parse is worse than none. SqlDriver's doc-comment asserted the opposite
premise — "config parsing belongs to the factory, which builds a fresh
instance" — which was false when written and is true only now.
Two green seals removed from ApplyChildDelta: it overwrote the cached Spec
synchronously BEFORE the child dequeued the message, so the host believed the
new config was live and the next reconcile computed no delta, sealing the
drift permanently; and it Tell'd with no Receive<ApplyResult> registered, so a
failed reinit — including Galaxy's deliberate NotSupportedException —
dead-lettered.
Exposed (not created) by the change: a factory throw is a CONFIG error, and
SpawnChild catches it and silently substitutes a stub. Only a brand-new driver
could reach that before; an ordinary config edit can now. Raised Warning ->
Error with an actionable message. It still does not fail the deployment —
doing so would let one malformed driver block a fleet deploy, so that is a
follow-up rather than a drive-by.
Tests: every pre-existing reinit test in these suites passes "{}", the exact
input a guarded re-parser treats as "keep constructor options" — blind to this
defect by construction. New tests pass CHANGED json; the Modbus one was
verified falsifiable by deleting the re-parse (goes red). Two tests asserted
the old behaviour and were rewritten, including the positive control in
DriverHostActorUnreadableArtifactTests, whose observable moved from
UnsubscribeAsync to ShutdownAsync now that teardown happens by stopping the
child rather than emptying its desired set.
Remaining full-suite failures are pre-existing and environmental, verified
identical on the pre-change tree: Host.IntegrationTests (3) and
Driver.AbLegacy.IntegrationTests (4, docker fixture-gated).
1336 lines
71 KiB
C#
1336 lines
71 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
|
|
{
|
|
private readonly FocasDriverOptions _options;
|
|
private readonly string _driverInstanceId;
|
|
private readonly IFocasClientFactory _clientFactory;
|
|
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);
|
|
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>
|
|
public FocasDriver(FocasDriverOptions options, string driverInstanceId,
|
|
IFocasClientFactory? clientFactory = null,
|
|
ILogger<FocasDriver>? logger = null)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(options);
|
|
_options = options;
|
|
_driverInstanceId = driverInstanceId;
|
|
_clientFactory = clientFactory ?? new Wire.WireFocasClientFactory();
|
|
_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>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"/> is deliberately <b>not</b> re-parsed here, unlike
|
|
/// Modbus / AbLegacy / OpcUaClient (Gitea #516). This driver builds more than options from config —
|
|
/// its <c>IFocasClientFactory</c> is selected from the <c>Backend</c> key and injected at
|
|
/// construction — so adopting a new <c>FocasDriverOptions</c> alone would run a NEW device/tag set
|
|
/// against the OLD backend. Half a re-parse is worse than none.</para>
|
|
/// <para>A config change is covered by the stop + respawn in <c>DriverSpawnPlanner</c>, which
|
|
/// rebuilds options and backend together through the factory.</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
|
|
{
|
|
// 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);
|
|
|
|
if (_options.FixedTree.Enabled)
|
|
{
|
|
foreach (var state in _devices.Values)
|
|
{
|
|
state.FixedTreeCts = new CancellationTokenSource();
|
|
var ct = state.FixedTreeCts.Token;
|
|
_ = Task.Run(() => FixedTreeLoopAsync(state, ct), ct);
|
|
}
|
|
}
|
|
|
|
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();
|
|
Volatile.Write(ref _health, new DriverHealth(DriverState.Unknown, Volatile.Read(ref _health).LastSuccessfulRead, null));
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public DriverHealth GetHealth() => Volatile.Read(ref _health);
|
|
/// <inheritdoc />
|
|
public long GetMemoryFootprint() => 0;
|
|
/// <inheritdoc />
|
|
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
|
|
|
/// <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.
|
|
if (_options.FixedTree.Enabled && 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 client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
|
var parsed = ResolveParsedAddress(def);
|
|
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 client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
|
var parsed = ResolveParsedAddress(def);
|
|
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 (_options.FixedTree.Enabled
|
|
&& _devices.TryGetValue(device.HostAddress, out var state)
|
|
&& state.FixedTreeCache is { } cache)
|
|
{
|
|
var identity = deviceFolder.Folder("Identity", "Identity");
|
|
EmitIdentityVariable(identity, device.HostAddress, "SeriesNumber", FocasDriverDataType.String);
|
|
EmitIdentityVariable(identity, device.HostAddress, "Version", FocasDriverDataType.String);
|
|
EmitIdentityVariable(identity, device.HostAddress, "MaxAxes", FocasDriverDataType.Int32);
|
|
EmitIdentityVariable(identity, device.HostAddress, "CncType", FocasDriverDataType.String);
|
|
EmitIdentityVariable(identity, device.HostAddress, "MtType", FocasDriverDataType.String);
|
|
EmitIdentityVariable(identity, device.HostAddress, "AxisCount", FocasDriverDataType.Int32);
|
|
|
|
var axesFolder = deviceFolder.Folder("Axes", "Axes");
|
|
foreach (var axis in cache.Axes)
|
|
{
|
|
var axisFolder = axesFolder.Folder(axis.Display, axis.Display);
|
|
EmitAxisVariable(axisFolder, device.HostAddress, axis.Display, "AbsolutePosition");
|
|
EmitAxisVariable(axisFolder, device.HostAddress, axis.Display, "MachinePosition");
|
|
EmitAxisVariable(axisFolder, device.HostAddress, axis.Display, "RelativePosition");
|
|
EmitAxisVariable(axisFolder, device.HostAddress, axis.Display, "DistanceToGo");
|
|
if (cache.Capabilities.ServoLoad)
|
|
EmitAxisVariable(axisFolder, device.HostAddress, axis.Display, "ServoLoad");
|
|
}
|
|
EmitAxisVariable(axesFolder, device.HostAddress, "FeedRate", "Actual");
|
|
EmitAxisVariable(axesFolder, device.HostAddress, "SpindleSpeed", "Actual");
|
|
|
|
// Spindle subtree — one folder per discovered spindle, suppressed
|
|
// entirely on series that don't export cnc_rdspdlname. Per-spindle
|
|
// Load + MaxRpm each gated on their own capability probe.
|
|
if (cache.Capabilities.Spindles)
|
|
{
|
|
var spindleRoot = deviceFolder.Folder("Spindle", "Spindle");
|
|
for (var i = 0; i < cache.Spindles.Count; i++)
|
|
{
|
|
var s = cache.Spindles[i];
|
|
var name = string.IsNullOrEmpty(s.Display) ? $"S{i + 1}" : s.Display;
|
|
var spindleFolder = spindleRoot.Folder(name, name);
|
|
if (cache.Capabilities.SpindleLoad)
|
|
EmitFixedVariable(spindleFolder, device.HostAddress, $"Spindle/{name}", "Load", DriverDataType.Int32);
|
|
if (cache.Capabilities.SpindleMaxRpm && i < cache.SpindleMaxRpms.Count)
|
|
EmitFixedVariable(spindleFolder, device.HostAddress, $"Spindle/{name}", "MaxRpm", DriverDataType.Int32);
|
|
}
|
|
}
|
|
|
|
// Fixed-tree T2 — Program + OperationMode subtrees (gated on capability).
|
|
if (cache.Capabilities.ProgramInfo)
|
|
{
|
|
var program = deviceFolder.Folder("Program", "Program");
|
|
EmitFixedVariable(program, device.HostAddress, "Program", "Name", DriverDataType.String);
|
|
EmitFixedVariable(program, device.HostAddress, "Program", "ONumber", DriverDataType.Int32);
|
|
EmitFixedVariable(program, device.HostAddress, "Program", "Number", DriverDataType.Int32);
|
|
EmitFixedVariable(program, device.HostAddress, "Program", "MainNumber", DriverDataType.Int32);
|
|
EmitFixedVariable(program, device.HostAddress, "Program", "Sequence", DriverDataType.Int32);
|
|
EmitFixedVariable(program, device.HostAddress, "Program", "BlockCount", DriverDataType.Int32);
|
|
|
|
var opMode = deviceFolder.Folder("OperationMode", "OperationMode");
|
|
EmitFixedVariable(opMode, device.HostAddress, "OperationMode", "Mode", DriverDataType.Int32);
|
|
EmitFixedVariable(opMode, device.HostAddress, "OperationMode", "ModeText", DriverDataType.String);
|
|
}
|
|
|
|
// Fixed-tree T3 — Timers subtree (power-on / operating / cutting / cycle).
|
|
if (cache.Capabilities.Timers)
|
|
{
|
|
var timers = deviceFolder.Folder("Timers", "Timers");
|
|
EmitFixedVariable(timers, device.HostAddress, "Timers", "PowerOnSeconds", DriverDataType.Float64);
|
|
EmitFixedVariable(timers, device.HostAddress, "Timers", "OperatingSeconds", DriverDataType.Float64);
|
|
EmitFixedVariable(timers, device.HostAddress, "Timers", "CuttingSeconds", DriverDataType.Float64);
|
|
EmitFixedVariable(timers, device.HostAddress, "Timers", "CycleSeconds", DriverDataType.Float64);
|
|
}
|
|
}
|
|
|
|
var tagsForDevice = _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));
|
|
}
|
|
}
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
private enum FocasDriverDataType { String, Int32, Float64 }
|
|
|
|
private static void EmitIdentityVariable(
|
|
IAddressSpaceBuilder folder, string deviceHost, string field, FocasDriverDataType type)
|
|
{
|
|
var fullName = FixedTreeReference(deviceHost, $"Identity/{field}");
|
|
folder.Variable(field, field, new DriverAttributeInfo(
|
|
FullName: fullName,
|
|
DriverDataType: type switch
|
|
{
|
|
FocasDriverDataType.Int32 => DriverDataType.Int32,
|
|
FocasDriverDataType.Float64 => DriverDataType.Float64,
|
|
_ => DriverDataType.String,
|
|
},
|
|
IsArray: false,
|
|
ArrayDim: null,
|
|
SecurityClass: SecurityClassification.ViewOnly,
|
|
IsHistorized: false,
|
|
IsAlarm: false,
|
|
WriteIdempotent: false));
|
|
}
|
|
|
|
private static void EmitAxisVariable(
|
|
IAddressSpaceBuilder folder, string deviceHost, string axisName, string field)
|
|
{
|
|
var fullName = FixedTreeReference(deviceHost, $"Axes/{axisName}/{field}");
|
|
folder.Variable(field, field, new DriverAttributeInfo(
|
|
FullName: fullName,
|
|
DriverDataType: DriverDataType.Float64,
|
|
IsArray: false,
|
|
ArrayDim: null,
|
|
SecurityClass: SecurityClassification.ViewOnly,
|
|
IsHistorized: false,
|
|
IsAlarm: false,
|
|
WriteIdempotent: false));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Emit a variable under a named fixed-tree folder (Program, OperationMode,
|
|
/// …). Full-reference shape is <c>{deviceHost}/{folderPath}/{field}</c>.
|
|
/// </summary>
|
|
private static void EmitFixedVariable(
|
|
IAddressSpaceBuilder folder, string deviceHost, string folderPath,
|
|
string field, DriverDataType type)
|
|
{
|
|
var fullName = FixedTreeReference(deviceHost, $"{folderPath}/{field}");
|
|
folder.Variable(field, field, new DriverAttributeInfo(
|
|
FullName: fullName,
|
|
DriverDataType: type,
|
|
IsArray: false,
|
|
ArrayDim: null,
|
|
SecurityClass: SecurityClassification.ViewOnly,
|
|
IsHistorized: false,
|
|
IsAlarm: false,
|
|
WriteIdempotent: false));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Canonical full-reference shape for a fixed-tree node. Keeps the device
|
|
/// host as a prefix so multi-device configs don't collide, and the rest is
|
|
/// the path inside the tree. Matches what poll-loop snapshots publish +
|
|
/// what <see cref="ReadAsync"/> looks up.
|
|
/// </summary>
|
|
/// <param name="deviceHost">The host address of the device.</param>
|
|
/// <param name="path">The path within the fixed tree.</param>
|
|
/// <returns>The canonical full reference 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);
|
|
}
|
|
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); }
|
|
}
|
|
|
|
// 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 (state.LastFixedSnapshots.TryGetValue(reference, out var raw))
|
|
return new DataValueSnapshot(raw, FocasStatusMapper.Good, now, now);
|
|
|
|
// Servo-load match: reference shape is "{host}/Axes/{name}/ServoLoad"
|
|
var suffixFull = reference[(state.Options.HostAddress.Length + 1)..];
|
|
if (suffixFull.StartsWith("Axes/", StringComparison.Ordinal) && suffixFull.EndsWith("/ServoLoad", StringComparison.Ordinal))
|
|
{
|
|
var axisName = suffixFull["Axes/".Length..^"/ServoLoad".Length];
|
|
if (state.LastServoLoads.TryGetValue(axisName, out var load))
|
|
return new DataValueSnapshot(load, FocasStatusMapper.Good, now, now);
|
|
}
|
|
|
|
// Spindle matches: "{host}/Spindle/{name}/Load" + "{host}/Spindle/{name}/MaxRpm"
|
|
if (suffixFull.StartsWith("Spindle/", StringComparison.Ordinal)
|
|
&& state.FixedTreeCache is { } spindleCache)
|
|
{
|
|
var tail = suffixFull["Spindle/".Length..];
|
|
var slash = tail.IndexOf('/');
|
|
if (slash > 0)
|
|
{
|
|
var spindleName = tail[..slash];
|
|
var field = tail[(slash + 1)..];
|
|
var idx = -1;
|
|
for (var i = 0; i < spindleCache.Spindles.Count; i++)
|
|
{
|
|
var s = spindleCache.Spindles[i];
|
|
var display = string.IsNullOrEmpty(s.Display) ? $"S{i + 1}" : s.Display;
|
|
if (string.Equals(display, spindleName, StringComparison.OrdinalIgnoreCase)) { idx = i; break; }
|
|
}
|
|
if (idx >= 0)
|
|
{
|
|
object? value = field switch
|
|
{
|
|
"Load" => state.LastSpindleLoads.TryGetValue(idx, out var l) ? (object)l : null,
|
|
"MaxRpm" => idx < spindleCache.SpindleMaxRpms.Count ? (object)spindleCache.SpindleMaxRpms[idx] : null,
|
|
_ => null,
|
|
};
|
|
if (value is not null)
|
|
return new DataValueSnapshot(value, FocasStatusMapper.Good, now, now);
|
|
}
|
|
}
|
|
}
|
|
// Identity strings + program / op-mode fields aren't cached as doubles —
|
|
// re-derive from the struct caches.
|
|
if (state.FixedTreeCache is { } cache)
|
|
{
|
|
var suffix = reference[(state.Options.HostAddress.Length + 1)..];
|
|
var value = suffix switch
|
|
{
|
|
"Identity/SeriesNumber" => (object)cache.SysInfo.Series,
|
|
"Identity/Version" => cache.SysInfo.Version,
|
|
"Identity/MaxAxes" => cache.SysInfo.MaxAxis,
|
|
"Identity/CncType" => cache.SysInfo.CncType,
|
|
"Identity/MtType" => cache.SysInfo.MtType,
|
|
"Identity/AxisCount" => cache.SysInfo.AxesCount,
|
|
"Program/Name" => (object?)state.LastProgramInfo?.Name,
|
|
"Program/ONumber" => state.LastProgramInfo?.ONumber,
|
|
"Program/BlockCount" => state.LastProgramInfo?.BlockCount,
|
|
"Program/Number" => state.LastProgramAxisRef?.ProgramNumber,
|
|
"Program/MainNumber" => state.LastProgramAxisRef?.MainProgramNumber,
|
|
"Program/Sequence" => state.LastProgramAxisRef?.SequenceNumber,
|
|
"OperationMode/Mode" => state.LastProgramInfo?.Mode,
|
|
"OperationMode/ModeText" => state.LastProgramInfo is { } pi
|
|
? FocasOpMode.ToText(pi.Mode) : null,
|
|
"Timers/PowerOnSeconds" => TimerValue(state, FocasTimerKind.PowerOn),
|
|
"Timers/OperatingSeconds" => TimerValue(state, FocasTimerKind.Operating),
|
|
"Timers/CuttingSeconds" => TimerValue(state, FocasTimerKind.Cutting),
|
|
"Timers/CycleSeconds" => TimerValue(state, FocasTimerKind.Cycle),
|
|
_ => null,
|
|
};
|
|
if (value is not null)
|
|
return new DataValueSnapshot(value, FocasStatusMapper.Good, now, now);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private async Task RecycleLoopAsync(DeviceState state, CancellationToken ct)
|
|
{
|
|
while (!ct.IsCancellationRequested)
|
|
{
|
|
try { await Task.Delay(_options.HandleRecycle.Interval, ct).ConfigureAwait(false); }
|
|
catch (OperationCanceledException) { break; }
|
|
|
|
// Close the current handle — the next Read / Write / Probe call triggers
|
|
// EnsureConnectedAsync, which reopens a fresh one. We don't block here on
|
|
// reconnect because the goal is just to release the FWLIB handle slot; a
|
|
// readable tick one probe cycle later is an acceptable cost.
|
|
try { state.DisposeClient(); }
|
|
catch (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 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;
|
|
}
|
|
}
|
|
}
|