1038 lines
49 KiB
C#
1038 lines
49 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, 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 readonly Dictionary<string, (string Host, string Field)> _statusNodesByName =
|
|
new(StringComparer.OrdinalIgnoreCase);
|
|
private readonly Dictionary<string, (string Host, string Field)> _productionNodesByName =
|
|
new(StringComparer.OrdinalIgnoreCase);
|
|
private readonly Dictionary<string, (string Host, string Field)> _modalNodesByName =
|
|
new(StringComparer.OrdinalIgnoreCase);
|
|
private readonly Dictionary<string, (string Host, string Field)> _overrideNodesByName =
|
|
new(StringComparer.OrdinalIgnoreCase);
|
|
private readonly Dictionary<string, string> _toolingNodesByName =
|
|
new(StringComparer.OrdinalIgnoreCase);
|
|
private readonly Dictionary<string, (string Host, string Slot, string Axis)> _offsetNodesByName =
|
|
new(StringComparer.OrdinalIgnoreCase);
|
|
private readonly Dictionary<string, string> _messagesNodesByName =
|
|
new(StringComparer.OrdinalIgnoreCase);
|
|
private readonly Dictionary<string, string> _currentBlockNodesByName =
|
|
new(StringComparer.OrdinalIgnoreCase);
|
|
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
|
|
|
/// <summary>
|
|
/// Names of the 9 fixed-tree <c>Status/</c> child nodes per device, mirroring the 9
|
|
/// fields of Fanuc's <c>cnc_rdcncstat</c> ODBST struct (issue #257). Order matters for
|
|
/// deterministic discovery output.
|
|
/// </summary>
|
|
private static readonly string[] StatusFieldNames =
|
|
[
|
|
"Tmmode", "Aut", "Run", "Motion", "Mstb", "EmergencyStop", "Alarm", "Edit", "Dummy",
|
|
];
|
|
|
|
/// <summary>
|
|
/// Names of the 4 fixed-tree <c>Production/</c> child nodes per device — parts
|
|
/// produced/required/total via <c>cnc_rdparam(6711/6712/6713)</c> + cycle-time
|
|
/// seconds (issue #258). Order matters for deterministic discovery output.
|
|
/// </summary>
|
|
private static readonly string[] ProductionFieldNames =
|
|
[
|
|
"PartsProduced", "PartsRequired", "PartsTotal", "CycleTimeSeconds",
|
|
];
|
|
|
|
/// <summary>
|
|
/// Names of the active modal aux-code child nodes per device — M/S/T/B from
|
|
/// <c>cnc_modal(type=100..103)</c> (issue #259). G-group decoding is a deferred
|
|
/// follow-up because the FWLIB <c>ODBMDL</c> union varies per series + group.
|
|
/// </summary>
|
|
private static readonly string[] ModalFieldNames = ["MCode", "SCode", "TCode", "BCode"];
|
|
|
|
/// <summary>
|
|
/// Names of the four operator-override child nodes per device — Feed / Rapid /
|
|
/// Spindle / Jog from <c>cnc_rdparam</c> with MTB-specific parameter numbers
|
|
/// (issue #259). A device whose <c>FocasOverrideParameters</c> entry is null for a
|
|
/// given field has the matching node omitted from the address space.
|
|
/// </summary>
|
|
private static readonly string[] OverrideFieldNames = ["Feed", "Rapid", "Spindle", "Jog"];
|
|
|
|
/// <summary>
|
|
/// Names of the standard work-coordinate offset slots surfaced under
|
|
/// <c>Offsets/</c> per device — G54..G59 from <c>cnc_rdzofs(n=1..6)</c>
|
|
/// (issue #260). Extended G54.1 P1..P48 surfaces are deferred to a follow-up
|
|
/// PR because <c>cnc_rdzofsr</c> uses a different range surface.
|
|
/// </summary>
|
|
private static readonly string[] WorkOffsetSlotNames =
|
|
[
|
|
"G54", "G55", "G56", "G57", "G58", "G59",
|
|
];
|
|
|
|
/// <summary>
|
|
/// Axis columns surfaced under each <c>Offsets/{slot}/</c> folder. Per the F1-d
|
|
/// plan a fixed 3-axis (X/Y/Z) view is used; lathes / mills with extra rotational
|
|
/// offsets get those columns exposed as 0.0 until a follow-up extends the surface.
|
|
/// </summary>
|
|
private static readonly string[] WorkOffsetAxisNames = ["X", "Y", "Z"];
|
|
|
|
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
|
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
|
|
|
public FocasDriver(FocasDriverOptions options, string driverInstanceId,
|
|
IFocasClientFactory? clientFactory = null)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(options);
|
|
_options = options;
|
|
_driverInstanceId = driverInstanceId;
|
|
_clientFactory = clientFactory ?? new FwlibFocasClientFactory();
|
|
_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;
|
|
}
|
|
|
|
// Per-device fixed-tree Status nodes — issue #257. Names are deterministic so
|
|
// ReadAsync can dispatch on the synthetic full-reference without extra metadata.
|
|
foreach (var device in _devices.Values)
|
|
{
|
|
foreach (var field in StatusFieldNames)
|
|
_statusNodesByName[StatusReferenceFor(device.Options.HostAddress, field)] =
|
|
(device.Options.HostAddress, field);
|
|
foreach (var field in ProductionFieldNames)
|
|
_productionNodesByName[ProductionReferenceFor(device.Options.HostAddress, field)] =
|
|
(device.Options.HostAddress, field);
|
|
foreach (var field in ModalFieldNames)
|
|
_modalNodesByName[ModalReferenceFor(device.Options.HostAddress, field)] =
|
|
(device.Options.HostAddress, field);
|
|
if (device.Options.OverrideParameters is { } op)
|
|
{
|
|
foreach (var field in OverrideFieldNames)
|
|
{
|
|
if (OverrideParamFor(op, field) is null) continue;
|
|
_overrideNodesByName[OverrideReferenceFor(device.Options.HostAddress, field)] =
|
|
(device.Options.HostAddress, field);
|
|
}
|
|
}
|
|
|
|
// Tooling/CurrentTool — single Int16 node per device (issue #260). Tool
|
|
// life + active offset index are deferred per the F1-d plan; they need
|
|
// ODBTLIFE* unions whose shape varies per series.
|
|
if (FocasCapabilityMatrix.SupportsTooling(device.Options.Series))
|
|
{
|
|
_toolingNodesByName[ToolingReferenceFor(device.Options.HostAddress, "CurrentTool")] =
|
|
device.Options.HostAddress;
|
|
}
|
|
|
|
// Offsets/{G54..G59}/{X|Y|Z} — fixed 3-axis view of the standard work-
|
|
// coordinate offsets (issue #260). Capability matrix gates by series so
|
|
// legacy CNCs that don't support cnc_rdzofs don't produce the subtree.
|
|
if (FocasCapabilityMatrix.SupportsWorkOffsets(device.Options.Series))
|
|
{
|
|
foreach (var slot in WorkOffsetSlotNames)
|
|
foreach (var axis in WorkOffsetAxisNames)
|
|
{
|
|
_offsetNodesByName[OffsetReferenceFor(device.Options.HostAddress, slot, axis)] =
|
|
(device.Options.HostAddress, slot, axis);
|
|
}
|
|
}
|
|
|
|
// Messages/External/Latest + Program/CurrentBlock — single String nodes per
|
|
// device backed by cnc_rdopmsg3 + cnc_rdactpt caches refreshed on the probe
|
|
// tick (issue #261). Permissive across series (no capability gate yet).
|
|
_messagesNodesByName[MessagesLatestReferenceFor(device.Options.HostAddress)] =
|
|
device.Options.HostAddress;
|
|
_currentBlockNodesByName[CurrentBlockReferenceFor(device.Options.HostAddress)] =
|
|
device.Options.HostAddress;
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
_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);
|
|
foreach (var state in _devices.Values)
|
|
{
|
|
try { state.ProbeCts?.Cancel(); } catch { }
|
|
state.ProbeCts?.Dispose();
|
|
state.ProbeCts = null;
|
|
state.DisposeClient();
|
|
}
|
|
_devices.Clear();
|
|
_tagsByName.Clear();
|
|
_statusNodesByName.Clear();
|
|
_productionNodesByName.Clear();
|
|
_modalNodesByName.Clear();
|
|
_overrideNodesByName.Clear();
|
|
_toolingNodesByName.Clear();
|
|
_offsetNodesByName.Clear();
|
|
_messagesNodesByName.Clear();
|
|
_currentBlockNodesByName.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 Status/ nodes — served from the per-device cached ODBST struct
|
|
// refreshed on the probe tick (issue #257). No wire call here.
|
|
if (_statusNodesByName.TryGetValue(reference, out var statusKey))
|
|
{
|
|
results[i] = ReadStatusField(statusKey.Host, statusKey.Field, now);
|
|
continue;
|
|
}
|
|
|
|
// Fixed-tree Production/ nodes — served from the per-device cached production
|
|
// snapshot refreshed on the probe tick (issue #258). No wire call here.
|
|
if (_productionNodesByName.TryGetValue(reference, out var prodKey))
|
|
{
|
|
results[i] = ReadProductionField(prodKey.Host, prodKey.Field, now);
|
|
continue;
|
|
}
|
|
|
|
// Fixed-tree Modal/ + Override/ nodes — served from per-device cached snapshots
|
|
// refreshed on the probe tick (issue #259). Same cache-or-Bad policy as Status/.
|
|
if (_modalNodesByName.TryGetValue(reference, out var modalKey))
|
|
{
|
|
results[i] = ReadModalField(modalKey.Host, modalKey.Field, now);
|
|
continue;
|
|
}
|
|
if (_overrideNodesByName.TryGetValue(reference, out var overrideKey))
|
|
{
|
|
results[i] = ReadOverrideField(overrideKey.Host, overrideKey.Field, now);
|
|
continue;
|
|
}
|
|
|
|
// Fixed-tree Tooling/CurrentTool — served from cached cnc_rdtnum snapshot
|
|
// refreshed on the probe tick (issue #260). No wire call here.
|
|
if (_toolingNodesByName.TryGetValue(reference, out var toolingHost))
|
|
{
|
|
results[i] = ReadToolingField(toolingHost, "CurrentTool", now);
|
|
continue;
|
|
}
|
|
|
|
// Fixed-tree Offsets/{slot}/{axis} — served from cached cnc_rdzofs(1..6)
|
|
// snapshot refreshed on the probe tick (issue #260). No wire call here.
|
|
if (_offsetNodesByName.TryGetValue(reference, out var offsetKey))
|
|
{
|
|
results[i] = ReadOffsetField(offsetKey.Host, offsetKey.Slot, offsetKey.Axis, now);
|
|
continue;
|
|
}
|
|
|
|
// Fixed-tree Messages/External/Latest + Program/CurrentBlock — served from
|
|
// cnc_rdopmsg3 + cnc_rdactpt caches refreshed on the probe tick (issue #261).
|
|
if (_messagesNodesByName.TryGetValue(reference, out var messagesHost))
|
|
{
|
|
results[i] = ReadMessagesLatestField(messagesHost, now);
|
|
continue;
|
|
}
|
|
if (_currentBlockNodesByName.TryGetValue(reference, out var blockHost))
|
|
{
|
|
results[i] = ReadCurrentBlockField(blockHost, now);
|
|
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);
|
|
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));
|
|
}
|
|
|
|
// Fixed-tree Status/ subfolder — 9 read-only Int16 nodes mirroring the ODBST
|
|
// fields (issue #257). Cached on the probe tick + served from DeviceState.LastStatus.
|
|
var statusFolder = deviceFolder.Folder("Status", "Status");
|
|
foreach (var field in StatusFieldNames)
|
|
{
|
|
var fullRef = StatusReferenceFor(device.HostAddress, field);
|
|
statusFolder.Variable(field, field, new DriverAttributeInfo(
|
|
FullName: fullRef,
|
|
DriverDataType: DriverDataType.Int16,
|
|
IsArray: false,
|
|
ArrayDim: null,
|
|
SecurityClass: SecurityClassification.ViewOnly,
|
|
IsHistorized: false,
|
|
IsAlarm: false,
|
|
WriteIdempotent: false));
|
|
}
|
|
|
|
// Fixed-tree Production/ subfolder — 4 read-only Int32 nodes: parts produced /
|
|
// required / total + cycle-time seconds (issue #258). Cached on the probe tick
|
|
// + served from DeviceState.LastProduction.
|
|
var productionFolder = deviceFolder.Folder("Production", "Production");
|
|
foreach (var field in ProductionFieldNames)
|
|
{
|
|
var fullRef = ProductionReferenceFor(device.HostAddress, field);
|
|
productionFolder.Variable(field, field, new DriverAttributeInfo(
|
|
FullName: fullRef,
|
|
DriverDataType: DriverDataType.Int32,
|
|
IsArray: false,
|
|
ArrayDim: null,
|
|
SecurityClass: SecurityClassification.ViewOnly,
|
|
IsHistorized: false,
|
|
IsAlarm: false,
|
|
WriteIdempotent: false));
|
|
}
|
|
|
|
// Fixed-tree Modal/ subfolder — 4 read-only Int16 nodes for the universally-
|
|
// present aux modal codes M/S/T/B from cnc_modal(type=100..103). G-group
|
|
// surfaces are deferred to a follow-up because the FWLIB ODBMDL union varies
|
|
// per series + group (issue #259, plan PR F1-c).
|
|
var modalFolder = deviceFolder.Folder("Modal", "Modal");
|
|
foreach (var field in ModalFieldNames)
|
|
{
|
|
var fullRef = ModalReferenceFor(device.HostAddress, field);
|
|
modalFolder.Variable(field, field, new DriverAttributeInfo(
|
|
FullName: fullRef,
|
|
DriverDataType: DriverDataType.Int16,
|
|
IsArray: false,
|
|
ArrayDim: null,
|
|
SecurityClass: SecurityClassification.ViewOnly,
|
|
IsHistorized: false,
|
|
IsAlarm: false,
|
|
WriteIdempotent: false));
|
|
}
|
|
|
|
// Fixed-tree Override/ subfolder — Feed / Rapid / Spindle / Jog from
|
|
// cnc_rdparam at MTB-specific parameter numbers (issue #259). Suppressed when
|
|
// OverrideParameters is null; per-field nodes whose parameter is null are
|
|
// omitted so a deployment can hide overrides their MTB doesn't wire up.
|
|
if (device.OverrideParameters is { } overrideParams)
|
|
{
|
|
var overrideFolder = deviceFolder.Folder("Override", "Override");
|
|
foreach (var field in OverrideFieldNames)
|
|
{
|
|
if (OverrideParamFor(overrideParams, field) is null) continue;
|
|
var fullRef = OverrideReferenceFor(device.HostAddress, field);
|
|
overrideFolder.Variable(field, field, new DriverAttributeInfo(
|
|
FullName: fullRef,
|
|
DriverDataType: DriverDataType.Int16,
|
|
IsArray: false,
|
|
ArrayDim: null,
|
|
SecurityClass: SecurityClassification.ViewOnly,
|
|
IsHistorized: false,
|
|
IsAlarm: false,
|
|
WriteIdempotent: false));
|
|
}
|
|
}
|
|
|
|
// Fixed-tree Tooling/ subfolder — single Int16 CurrentTool node from
|
|
// cnc_rdtnum (issue #260). Tool life + active offset index are deferred
|
|
// per the F1-d plan because the FWLIB ODBTLIFE* unions vary per series.
|
|
if (FocasCapabilityMatrix.SupportsTooling(device.Series))
|
|
{
|
|
var toolingFolder = deviceFolder.Folder("Tooling", "Tooling");
|
|
var toolingRef = ToolingReferenceFor(device.HostAddress, "CurrentTool");
|
|
toolingFolder.Variable("CurrentTool", "CurrentTool", new DriverAttributeInfo(
|
|
FullName: toolingRef,
|
|
DriverDataType: DriverDataType.Int16,
|
|
IsArray: false,
|
|
ArrayDim: null,
|
|
SecurityClass: SecurityClassification.ViewOnly,
|
|
IsHistorized: false,
|
|
IsAlarm: false,
|
|
WriteIdempotent: false));
|
|
}
|
|
|
|
// Fixed-tree Offsets/ subfolder — G54..G59 each with X/Y/Z Float64 axes
|
|
// from cnc_rdzofs(n=1..6) (issue #260). Capability matrix gates the surface
|
|
// by series so legacy controllers without cnc_rdzofs support don't expose
|
|
// dead nodes. Extended G54.1 P1..P48 surfaces are deferred to a follow-up.
|
|
if (FocasCapabilityMatrix.SupportsWorkOffsets(device.Series))
|
|
{
|
|
var offsetsFolder = deviceFolder.Folder("Offsets", "Offsets");
|
|
foreach (var slot in WorkOffsetSlotNames)
|
|
{
|
|
var slotFolder = offsetsFolder.Folder(slot, slot);
|
|
foreach (var axis in WorkOffsetAxisNames)
|
|
{
|
|
var fullRef = OffsetReferenceFor(device.HostAddress, slot, axis);
|
|
slotFolder.Variable(axis, axis, new DriverAttributeInfo(
|
|
FullName: fullRef,
|
|
DriverDataType: DriverDataType.Float64,
|
|
IsArray: false,
|
|
ArrayDim: null,
|
|
SecurityClass: SecurityClassification.ViewOnly,
|
|
IsHistorized: false,
|
|
IsAlarm: false,
|
|
WriteIdempotent: false));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fixed-tree Messages/External/Latest — single String node per device backed
|
|
// by cnc_rdopmsg3 across the four FANUC operator-message classes (issue #261).
|
|
// The issue body permits this minimal "latest message" surface in the first
|
|
// cut over a full ring-buffer of all four slots.
|
|
var messagesFolder = deviceFolder.Folder("Messages", "Messages");
|
|
var externalFolder = messagesFolder.Folder("External", "External");
|
|
var messagesRef = MessagesLatestReferenceFor(device.HostAddress);
|
|
externalFolder.Variable("Latest", "Latest", new DriverAttributeInfo(
|
|
FullName: messagesRef,
|
|
DriverDataType: DriverDataType.String,
|
|
IsArray: false,
|
|
ArrayDim: null,
|
|
SecurityClass: SecurityClassification.ViewOnly,
|
|
IsHistorized: false,
|
|
IsAlarm: false,
|
|
WriteIdempotent: false));
|
|
|
|
// Fixed-tree Program/CurrentBlock — single String node per device backed by
|
|
// cnc_rdactpt (issue #261). Trim-stable round-trip per the issue body.
|
|
var programFolder = deviceFolder.Folder("Program", "Program");
|
|
var blockRef = CurrentBlockReferenceFor(device.HostAddress);
|
|
programFolder.Variable("CurrentBlock", "CurrentBlock", new DriverAttributeInfo(
|
|
FullName: blockRef,
|
|
DriverDataType: DriverDataType.String,
|
|
IsArray: false,
|
|
ArrayDim: null,
|
|
SecurityClass: SecurityClassification.ViewOnly,
|
|
IsHistorized: false,
|
|
IsAlarm: false,
|
|
WriteIdempotent: false));
|
|
}
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
private static string StatusReferenceFor(string hostAddress, string field) =>
|
|
$"{hostAddress}::Status/{field}";
|
|
|
|
private static string ProductionReferenceFor(string hostAddress, string field) =>
|
|
$"{hostAddress}::Production/{field}";
|
|
|
|
private static string ModalReferenceFor(string hostAddress, string field) =>
|
|
$"{hostAddress}::Modal/{field}";
|
|
|
|
private static string OverrideReferenceFor(string hostAddress, string field) =>
|
|
$"{hostAddress}::Override/{field}";
|
|
|
|
private static string ToolingReferenceFor(string hostAddress, string field) =>
|
|
$"{hostAddress}::Tooling/{field}";
|
|
|
|
private static string OffsetReferenceFor(string hostAddress, string slot, string axis) =>
|
|
$"{hostAddress}::Offsets/{slot}/{axis}";
|
|
|
|
private static string MessagesLatestReferenceFor(string hostAddress) =>
|
|
$"{hostAddress}::Messages/External/Latest";
|
|
|
|
private static string CurrentBlockReferenceFor(string hostAddress) =>
|
|
$"{hostAddress}::Program/CurrentBlock";
|
|
|
|
private static ushort? OverrideParamFor(FocasOverrideParameters p, string field) => field switch
|
|
{
|
|
"Feed" => p.FeedParam,
|
|
"Rapid" => p.RapidParam,
|
|
"Spindle" => p.SpindleParam,
|
|
"Jog" => p.JogParam,
|
|
_ => null,
|
|
};
|
|
|
|
private static short? PickStatusField(FocasStatusInfo s, string field) => field switch
|
|
{
|
|
"Tmmode" => s.Tmmode,
|
|
"Aut" => s.Aut,
|
|
"Run" => s.Run,
|
|
"Motion" => s.Motion,
|
|
"Mstb" => s.Mstb,
|
|
"EmergencyStop" => s.EmergencyStop,
|
|
"Alarm" => s.Alarm,
|
|
"Edit" => s.Edit,
|
|
"Dummy" => s.Dummy,
|
|
_ => null,
|
|
};
|
|
|
|
private static int? PickProductionField(FocasProductionInfo p, string field) => field switch
|
|
{
|
|
"PartsProduced" => p.PartsProduced,
|
|
"PartsRequired" => p.PartsRequired,
|
|
"PartsTotal" => p.PartsTotal,
|
|
"CycleTimeSeconds" => p.CycleTimeSeconds,
|
|
_ => null,
|
|
};
|
|
|
|
private static short? PickModalField(FocasModalInfo m, string field) => field switch
|
|
{
|
|
"MCode" => m.MCode,
|
|
"SCode" => m.SCode,
|
|
"TCode" => m.TCode,
|
|
"BCode" => m.BCode,
|
|
_ => null,
|
|
};
|
|
|
|
private static short? PickOverrideField(FocasOverrideInfo o, string field) => field switch
|
|
{
|
|
"Feed" => o.Feed,
|
|
"Rapid" => o.Rapid,
|
|
"Spindle" => o.Spindle,
|
|
"Jog" => o.Jog,
|
|
_ => null,
|
|
};
|
|
|
|
// ---- 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);
|
|
if (success)
|
|
{
|
|
// Refresh the cached ODBST status snapshot on every probe tick — this is
|
|
// what the Status/ fixed-tree nodes serve from. Best-effort: a null result
|
|
// (older IFocasClient impls without GetStatusAsync) just leaves the cache
|
|
// unchanged so the previous good snapshot keeps serving until refreshed.
|
|
var snapshot = await client.GetStatusAsync(ct).ConfigureAwait(false);
|
|
if (snapshot is not null)
|
|
{
|
|
state.LastStatus = snapshot;
|
|
state.LastStatusUtc = DateTime.UtcNow;
|
|
}
|
|
// Refresh the cached production snapshot too — same best-effort policy
|
|
// as Status/: a null result leaves the previous good snapshot in place
|
|
// so reads keep serving until the next successful refresh (issue #258).
|
|
var production = await client.GetProductionAsync(ct).ConfigureAwait(false);
|
|
if (production is not null)
|
|
{
|
|
state.LastProduction = production;
|
|
state.LastProductionUtc = DateTime.UtcNow;
|
|
}
|
|
// Modal aux M/S/T/B + per-device operator overrides — same best-effort
|
|
// policy as Status/ + Production/. Override snapshot is suppressed when
|
|
// the device has no OverrideParameters configured (issue #259).
|
|
var modal = await client.GetModalAsync(ct).ConfigureAwait(false);
|
|
if (modal is not null)
|
|
{
|
|
state.LastModal = modal;
|
|
state.LastModalUtc = DateTime.UtcNow;
|
|
}
|
|
if (state.Options.OverrideParameters is { } overrideParams)
|
|
{
|
|
var ov = await client.GetOverrideAsync(overrideParams, ct).ConfigureAwait(false);
|
|
if (ov is not null)
|
|
{
|
|
state.LastOverride = ov;
|
|
state.LastOverrideUtc = DateTime.UtcNow;
|
|
}
|
|
}
|
|
// Tooling/CurrentTool + Offsets/{G54..G59}/{X|Y|Z} — same best-
|
|
// effort policy as the other fixed-tree caches (issue #260). A
|
|
// null result leaves the previous good snapshot in place so reads
|
|
// keep serving until the next successful refresh.
|
|
if (FocasCapabilityMatrix.SupportsTooling(state.Options.Series))
|
|
{
|
|
var tooling = await client.GetToolingAsync(ct).ConfigureAwait(false);
|
|
if (tooling is not null)
|
|
{
|
|
state.LastTooling = tooling;
|
|
state.LastToolingUtc = DateTime.UtcNow;
|
|
}
|
|
}
|
|
if (FocasCapabilityMatrix.SupportsWorkOffsets(state.Options.Series))
|
|
{
|
|
var offsets = await client.GetWorkOffsetsAsync(ct).ConfigureAwait(false);
|
|
if (offsets is not null)
|
|
{
|
|
state.LastWorkOffsets = offsets;
|
|
state.LastWorkOffsetsUtc = DateTime.UtcNow;
|
|
}
|
|
}
|
|
|
|
// Operator messages + currently-executing block — same best-effort
|
|
// policy as the other fixed-tree caches (issue #261). A null result
|
|
// leaves the previous good snapshot in place so reads keep serving
|
|
// until the next successful refresh.
|
|
var messages = await client.GetOperatorMessagesAsync(ct).ConfigureAwait(false);
|
|
if (messages is not null)
|
|
{
|
|
state.LastMessages = messages;
|
|
state.LastMessagesUtc = DateTime.UtcNow;
|
|
}
|
|
var block = await client.GetCurrentBlockAsync(ct).ConfigureAwait(false);
|
|
if (block is not null)
|
|
{
|
|
state.LastCurrentBlock = block;
|
|
state.LastCurrentBlockUtc = DateTime.UtcNow;
|
|
}
|
|
}
|
|
}
|
|
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; }
|
|
}
|
|
}
|
|
|
|
private DataValueSnapshot ReadStatusField(string hostAddress, string field, DateTime now)
|
|
{
|
|
if (!_devices.TryGetValue(hostAddress, out var device))
|
|
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
|
if (device.LastStatus is not { } snap)
|
|
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
|
|
var value = PickStatusField(snap, field);
|
|
if (value is null)
|
|
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
|
return new DataValueSnapshot((short)value, FocasStatusMapper.Good,
|
|
device.LastStatusUtc, now);
|
|
}
|
|
|
|
private DataValueSnapshot ReadProductionField(string hostAddress, string field, DateTime now)
|
|
{
|
|
if (!_devices.TryGetValue(hostAddress, out var device))
|
|
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
|
if (device.LastProduction is not { } snap)
|
|
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
|
|
var value = PickProductionField(snap, field);
|
|
if (value is null)
|
|
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
|
return new DataValueSnapshot((int)value, FocasStatusMapper.Good,
|
|
device.LastProductionUtc, now);
|
|
}
|
|
|
|
private DataValueSnapshot ReadModalField(string hostAddress, string field, DateTime now)
|
|
{
|
|
if (!_devices.TryGetValue(hostAddress, out var device))
|
|
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
|
if (device.LastModal is not { } snap)
|
|
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
|
|
var value = PickModalField(snap, field);
|
|
if (value is null)
|
|
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
|
return new DataValueSnapshot((short)value, FocasStatusMapper.Good,
|
|
device.LastModalUtc, now);
|
|
}
|
|
|
|
private DataValueSnapshot ReadOverrideField(string hostAddress, string field, DateTime now)
|
|
{
|
|
if (!_devices.TryGetValue(hostAddress, out var device))
|
|
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
|
if (device.LastOverride is not { } snap)
|
|
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
|
|
var value = PickOverrideField(snap, field);
|
|
if (value is null)
|
|
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
|
return new DataValueSnapshot((short)value, FocasStatusMapper.Good,
|
|
device.LastOverrideUtc, now);
|
|
}
|
|
|
|
private DataValueSnapshot ReadToolingField(string hostAddress, string field, DateTime now)
|
|
{
|
|
if (!_devices.TryGetValue(hostAddress, out var device))
|
|
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
|
if (device.LastTooling is not { } snap)
|
|
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
|
|
return field switch
|
|
{
|
|
"CurrentTool" => new DataValueSnapshot(snap.CurrentTool, FocasStatusMapper.Good,
|
|
device.LastToolingUtc, now),
|
|
_ => new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now),
|
|
};
|
|
}
|
|
|
|
private DataValueSnapshot ReadOffsetField(string hostAddress, string slot, string axis, DateTime now)
|
|
{
|
|
if (!_devices.TryGetValue(hostAddress, out var device))
|
|
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
|
if (device.LastWorkOffsets is not { } snap)
|
|
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
|
|
var match = snap.Offsets.FirstOrDefault(o =>
|
|
string.Equals(o.Name, slot, StringComparison.OrdinalIgnoreCase));
|
|
if (match is null)
|
|
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
|
var value = axis switch
|
|
{
|
|
"X" => (double?)match.X,
|
|
"Y" => match.Y,
|
|
"Z" => match.Z,
|
|
_ => null,
|
|
};
|
|
if (value is null)
|
|
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
|
return new DataValueSnapshot(value.Value, FocasStatusMapper.Good,
|
|
device.LastWorkOffsetsUtc, now);
|
|
}
|
|
|
|
private DataValueSnapshot ReadMessagesLatestField(string hostAddress, DateTime now)
|
|
{
|
|
if (!_devices.TryGetValue(hostAddress, out var device))
|
|
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
|
if (device.LastMessages is not { } snap)
|
|
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
|
|
// Snapshot is the trimmed list of active classes. "Latest" surfaces the last
|
|
// (most-recent) entry — the issue body permits this minimal "latest message"
|
|
// surface in lieu of a full ring buffer of all 4 classes.
|
|
var latest = snap.Messages.Count == 0
|
|
? string.Empty
|
|
: snap.Messages[snap.Messages.Count - 1].Text;
|
|
return new DataValueSnapshot(latest, FocasStatusMapper.Good,
|
|
device.LastMessagesUtc, now);
|
|
}
|
|
|
|
private DataValueSnapshot ReadCurrentBlockField(string hostAddress, DateTime now)
|
|
{
|
|
if (!_devices.TryGetValue(hostAddress, out var device))
|
|
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
|
if (device.LastCurrentBlock is not { } snap)
|
|
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
|
|
return new DataValueSnapshot(snap.Text, FocasStatusMapper.Good,
|
|
device.LastCurrentBlockUtc, now);
|
|
}
|
|
|
|
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));
|
|
}
|
|
|
|
// ---- 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);
|
|
|
|
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; }
|
|
|
|
/// <summary>
|
|
/// Cached <c>cnc_rdcncstat</c> snapshot, refreshed on every probe tick. Reads of
|
|
/// the per-device <c>Status/<field></c> fixed-tree nodes serve from this cache
|
|
/// so they don't pile extra wire traffic on top of the user-driven tag reads.
|
|
/// </summary>
|
|
public FocasStatusInfo? LastStatus { get; set; }
|
|
public DateTime LastStatusUtc { get; set; }
|
|
|
|
/// <summary>
|
|
/// Cached <c>cnc_rdparam(6711/6712/6713)</c> + cycle-time snapshot, refreshed on
|
|
/// every probe tick. Reads of the per-device <c>Production/<field></c>
|
|
/// fixed-tree nodes serve from this cache so they don't pile extra wire traffic
|
|
/// on top of the user-driven tag reads (issue #258).
|
|
/// </summary>
|
|
public FocasProductionInfo? LastProduction { get; set; }
|
|
public DateTime LastProductionUtc { get; set; }
|
|
|
|
/// <summary>
|
|
/// Cached <c>cnc_modal</c> M/S/T/B snapshot, refreshed on every probe tick.
|
|
/// Reads of the per-device <c>Modal/<field></c> nodes serve from this cache
|
|
/// so they don't pile extra wire traffic on top of user-driven reads (issue #259).
|
|
/// </summary>
|
|
public FocasModalInfo? LastModal { get; set; }
|
|
public DateTime LastModalUtc { get; set; }
|
|
|
|
/// <summary>
|
|
/// Cached <c>cnc_rdparam</c> override snapshot, refreshed on every probe tick.
|
|
/// Suppressed when the device's <see cref="FocasDeviceOptions.OverrideParameters"/>
|
|
/// is null (no <c>Override/</c> nodes are exposed in that case — issue #259).
|
|
/// </summary>
|
|
public FocasOverrideInfo? LastOverride { get; set; }
|
|
public DateTime LastOverrideUtc { get; set; }
|
|
|
|
/// <summary>
|
|
/// Cached <c>cnc_rdtnum</c> snapshot — current tool number — refreshed on
|
|
/// every probe tick. Reads of <c>Tooling/CurrentTool</c> serve from this
|
|
/// cache so they don't pile extra wire traffic on top of user-driven
|
|
/// reads (issue #260).
|
|
/// </summary>
|
|
public FocasToolingInfo? LastTooling { get; set; }
|
|
public DateTime LastToolingUtc { get; set; }
|
|
|
|
/// <summary>
|
|
/// Cached <c>cnc_rdzofs(1..6)</c> snapshot — G54..G59 work-coordinate
|
|
/// offsets — refreshed on every probe tick. Reads of
|
|
/// <c>Offsets/{slot}/{X|Y|Z}</c> serve from this cache (issue #260).
|
|
/// </summary>
|
|
public FocasWorkOffsetsInfo? LastWorkOffsets { get; set; }
|
|
public DateTime LastWorkOffsetsUtc { get; set; }
|
|
|
|
/// <summary>
|
|
/// Cached <c>cnc_rdopmsg3</c> snapshot — active operator messages across
|
|
/// the four FANUC classes — refreshed on every probe tick. Reads of
|
|
/// <c>Messages/External/Latest</c> serve from this cache (issue #261).
|
|
/// </summary>
|
|
public FocasOperatorMessagesInfo? LastMessages { get; set; }
|
|
public DateTime LastMessagesUtc { get; set; }
|
|
|
|
/// <summary>
|
|
/// Cached <c>cnc_rdactpt</c> snapshot — currently-executing block text —
|
|
/// refreshed on every probe tick. Reads of <c>Program/CurrentBlock</c>
|
|
/// serve from this cache (issue #261).
|
|
/// </summary>
|
|
public FocasCurrentBlockInfo? LastCurrentBlock { get; set; }
|
|
public DateTime LastCurrentBlockUtc { get; set; }
|
|
|
|
public void DisposeClient()
|
|
{
|
|
Client?.Dispose();
|
|
Client = null;
|
|
}
|
|
}
|
|
}
|