Group all 69 projects into category subfolders under src/ and tests/ so the Rider Solution Explorer mirrors the module structure. Folders: Core, Server, Drivers (with a nested Driver CLIs subfolder), Client, Tooling. - Move every project folder on disk with git mv (history preserved as renames). - Recompute relative paths in 57 .csproj files: cross-category ProjectReferences, the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external mxaccessgw refs in Driver.Galaxy and its test project. - Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders. - Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL, integration, install). Build green (0 errors); unit tests pass. Docs left for a separate pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
934 lines
45 KiB
C#
934 lines
45 KiB
C#
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 Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
|
private readonly Dictionary<string, FocasTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
|
private FocasAlarmProjection? _alarmProjection;
|
|
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
|
|
|
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
|
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
|
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
|
|
|
|
public FocasDriver(FocasDriverOptions options, string driverInstanceId,
|
|
IFocasClientFactory? clientFactory = null)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(options);
|
|
_options = options;
|
|
_driverInstanceId = driverInstanceId;
|
|
_clientFactory = clientFactory ?? new Wire.WireFocasClientFactory();
|
|
_poll = new PollGroupEngine(
|
|
reader: ReadAsync,
|
|
onChange: (handle, tagRef, snapshot) =>
|
|
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)));
|
|
}
|
|
|
|
public string DriverInstanceId => _driverInstanceId;
|
|
public string DriverType => "FOCAS";
|
|
|
|
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
|
{
|
|
_health = new DriverHealth(DriverState.Initializing, null, null);
|
|
try
|
|
{
|
|
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);
|
|
}
|
|
// Pre-flight: validate every tag's address against the declared CNC
|
|
// series so misconfigured addresses fail at init (clear config error)
|
|
// instead of producing BadOutOfRange on every read at runtime.
|
|
// Series=Unknown short-circuits the matrix; pre-matrix configs stay permissive.
|
|
foreach (var tag in _options.Tags)
|
|
{
|
|
var parsed = FocasAddress.TryParse(tag.Address)
|
|
?? throw new InvalidOperationException(
|
|
$"FOCAS tag '{tag.Name}' has invalid Address '{tag.Address}'. " +
|
|
$"Expected forms: R100, R100.3, PARAM:1815/0, MACRO:500.");
|
|
if (_devices.TryGetValue(tag.DeviceHostAddress, out var device)
|
|
&& FocasCapabilityMatrix.Validate(device.Options.Series, parsed) is { } reason)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"FOCAS tag '{tag.Name}' ({tag.Address}) rejected by capability matrix: {reason}");
|
|
}
|
|
_tagsByName[tag.Name] = tag;
|
|
}
|
|
|
|
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);
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_health = new DriverHealth(DriverState.Faulted, null, ex.Message);
|
|
throw;
|
|
}
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
|
{
|
|
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
|
|
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
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)
|
|
{
|
|
try { state.ProbeCts?.Cancel(); } catch { }
|
|
state.ProbeCts?.Dispose();
|
|
state.ProbeCts = null;
|
|
try { state.RecycleCts?.Cancel(); } catch { }
|
|
state.RecycleCts?.Dispose();
|
|
state.RecycleCts = null;
|
|
try { state.FixedTreeCts?.Cancel(); } catch { }
|
|
state.FixedTreeCts?.Dispose();
|
|
state.FixedTreeCts = null;
|
|
state.DisposeClient();
|
|
}
|
|
_devices.Clear();
|
|
_tagsByName.Clear();
|
|
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
|
}
|
|
|
|
public DriverHealth GetHealth() => _health;
|
|
public long GetMemoryFootprint() => 0;
|
|
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
|
|
|
internal int DeviceCount => _devices.Count;
|
|
internal DeviceState? GetDeviceState(string hostAddress) =>
|
|
_devices.TryGetValue(hostAddress, out var s) ? s : null;
|
|
|
|
// ---- IReadable ----
|
|
|
|
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 (!_tagsByName.TryGetValue(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 = FocasAddress.TryParse(def.Address)
|
|
?? throw new InvalidOperationException($"FOCAS tag '{def.Name}' has malformed Address '{def.Address}'.");
|
|
var (value, status) = await client.ReadAsync(parsed, def.DataType, cancellationToken).ConfigureAwait(false);
|
|
|
|
results[i] = new DataValueSnapshot(value, status, now, now);
|
|
if (status == FocasStatusMapper.Good)
|
|
_health = new DriverHealth(DriverState.Healthy, now, null);
|
|
else
|
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
|
|
$"FOCAS status 0x{status:X8} reading {reference}");
|
|
}
|
|
catch (OperationCanceledException) { throw; }
|
|
catch (Exception ex)
|
|
{
|
|
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
|
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
// ---- IWritable ----
|
|
|
|
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 (!_tagsByName.TryGetValue(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 = FocasAddress.TryParse(def.Address)
|
|
?? throw new InvalidOperationException($"FOCAS tag '{def.Name}' has malformed Address '{def.Address}'.");
|
|
var status = await client.WriteAsync(parsed, def.DataType, w.Value, cancellationToken).ConfigureAwait(false);
|
|
results[i] = new WriteResult(status);
|
|
}
|
|
catch (OperationCanceledException) { throw; }
|
|
catch (NotSupportedException nse)
|
|
{
|
|
results[i] = new WriteResult(FocasStatusMapper.BadNotSupported);
|
|
_health = new DriverHealth(DriverState.Degraded, _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);
|
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
// ---- ITagDiscovery ----
|
|
|
|
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 = _options.Tags.Where(t =>
|
|
string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
|
|
foreach (var tag in tagsForDevice)
|
|
{
|
|
deviceFolder.Variable(tag.Name, tag.Name, new DriverAttributeInfo(
|
|
FullName: tag.Name,
|
|
DriverDataType: tag.DataType.ToDriverDataType(),
|
|
IsArray: false,
|
|
ArrayDim: null,
|
|
SecurityClass: tag.Writable
|
|
? SecurityClassification.Operate
|
|
: 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>
|
|
internal static string FixedTreeReference(string deviceHost, string path) =>
|
|
$"{deviceHost}/{path}";
|
|
|
|
// ---- ISubscribable (polling overlay via shared engine) ----
|
|
|
|
public Task<ISubscriptionHandle> SubscribeAsync(
|
|
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) =>
|
|
Task.FromResult(_poll.Subscribe(fullReferences, publishingInterval));
|
|
|
|
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
|
|
{
|
|
_poll.Unsubscribe(handle);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
// ---- IHostConnectivityProbe ----
|
|
|
|
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
|
|
{
|
|
var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
|
|
success = await client.ProbeAsync(ct).ConfigureAwait(false);
|
|
}
|
|
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
|
|
catch { /* connect-failure path already disposed + cleared the client */ }
|
|
|
|
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);
|
|
|
|
// 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
|
|
{
|
|
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 { /* first-tick poll will retry */ }
|
|
}
|
|
|
|
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);
|
|
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 { /* transient — next tick retries */ }
|
|
}
|
|
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 { /* transient */ }
|
|
}
|
|
|
|
// 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 { /* transient — next tick retries */ }
|
|
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 { /* per-kind failures are non-fatal */ }
|
|
}
|
|
timerPollDue = DateTime.UtcNow + timerInterval;
|
|
}
|
|
}
|
|
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
|
|
catch { /* next tick retries — transient blips are expected */ }
|
|
|
|
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)
|
|
{
|
|
var host = state.Options.HostAddress;
|
|
state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axis.Display}/AbsolutePosition")] = snap.AbsolutePosition;
|
|
state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axis.Display}/MachinePosition")] = snap.MachinePosition;
|
|
state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axis.Display}/RelativePosition")] = snap.RelativePosition;
|
|
state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axis.Display}/DistanceToGo")] = snap.DistanceToGo;
|
|
}
|
|
|
|
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 int-typed) so the double-valued load
|
|
/// values don't need casting on every read.
|
|
/// </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((double)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 { /* already disposed or race — next EnsureConnected recovers */ }
|
|
}
|
|
}
|
|
|
|
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 ----
|
|
|
|
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);
|
|
}
|
|
|
|
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) =>
|
|
_alarmProjection is { } p ? p.UnsubscribeAsync(handle, cancellationToken) : Task.CompletedTask;
|
|
|
|
public Task AcknowledgeAsync(
|
|
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken) =>
|
|
_alarmProjection is { } p ? p.AcknowledgeAsync(acknowledgements, cancellationToken) : Task.CompletedTask;
|
|
|
|
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>
|
|
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 { /* surface a device-local fault on the next tick */ }
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// ---- IPerCallHostResolver ----
|
|
|
|
public string ResolveHost(string fullReference)
|
|
{
|
|
if (_tagsByName.TryGetValue(fullReference, out var def))
|
|
return def.DeviceHostAddress;
|
|
return _options.Devices.FirstOrDefault()?.HostAddress ?? DriverInstanceId;
|
|
}
|
|
|
|
private async Task<IFocasClient> EnsureConnectedAsync(DeviceState device, CancellationToken ct)
|
|
{
|
|
if (device.Client is { IsConnected: true } c) return c;
|
|
device.Client ??= _clientFactory.Create();
|
|
try
|
|
{
|
|
await device.Client.ConnectAsync(device.ParsedAddress, _options.Timeout, ct).ConfigureAwait(false);
|
|
}
|
|
catch
|
|
{
|
|
device.Client.Dispose();
|
|
device.Client = null;
|
|
throw;
|
|
}
|
|
return device.Client;
|
|
}
|
|
|
|
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
|
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)
|
|
{
|
|
public FocasHostAddress ParsedAddress { get; } = parsedAddress;
|
|
public FocasDeviceOptions Options { get; } = options;
|
|
public IFocasClient? Client { get; set; }
|
|
|
|
public object ProbeLock { get; } = new();
|
|
public HostState HostState { get; set; } = HostState.Unknown;
|
|
public DateTime HostStateChangedUtc { get; set; } = DateTime.UtcNow;
|
|
public CancellationTokenSource? ProbeCts { get; set; }
|
|
public CancellationTokenSource? RecycleCts { get; set; }
|
|
public CancellationTokenSource? FixedTreeCts { get; set; }
|
|
public FocasFixedTreeCache? FixedTreeCache { get; set; }
|
|
public Dictionary<string, int> LastFixedSnapshots { get; } = new(StringComparer.OrdinalIgnoreCase);
|
|
public FocasProgramInfo? LastProgramInfo { get; set; }
|
|
/// <summary>Cached first-axis dynamic snapshot — feeds Program/Number, /MainNumber, /Sequence.</summary>
|
|
public FocasDynamicSnapshot? LastProgramAxisRef { get; set; }
|
|
public Dictionary<FocasTimerKind, FocasTimer> LastTimers { get; } = [];
|
|
public Dictionary<string, double> LastServoLoads { get; } = new(StringComparer.OrdinalIgnoreCase);
|
|
public Dictionary<int, int> LastSpindleLoads { get; } = [];
|
|
|
|
public void DisposeClient()
|
|
{
|
|
Client?.Dispose();
|
|
Client = null;
|
|
}
|
|
}
|
|
}
|