Files
lmxopcua/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
T
Joseph Doherty 7f9d6a778e Auto: focas-f3a — cnc_rdalmhistry alarm-history extension
Adds FocasAlarmProjection with two modes (ActiveOnly default, ActivePlusHistory)
that polls cnc_rdalmhistry on connect + on a configurable cadence (5 min default,
HistoryDepth=100 capped at 250). Emits historic events via IAlarmSource with
SourceTimestampUtc set from the CNC's reported timestamp; dedup keyed on
(OccurrenceTime, AlarmNumber, AlarmType). Ships the ODBALMHIS packed-buffer
decoder + encoder in Wire/FocasAlarmHistoryDecoder.cs and threads
ReadAlarmHistoryAsync through IFocasClient (default no-op so existing transport
variants stay back-compat). FocasDriver now implements IAlarmSource.

13 new unit tests cover: mode switch, dedup, distinct-timestamp emission,
type-as-key behaviour, OccurrenceTime passthrough (not Now), HistoryDepth
clamp/fallback, and decoder round-trip. All 341 FOCAS unit tests still pass.

Docs: docs/drivers/FOCAS.md (new), docs/v2/focas-deployment.md (new),
docs/v2/implementation/focas-wire-protocol.md (new),
docs/v2/implementation/focas-simulator-plan.md (new),
docs/drivers/FOCAS-Test-Fixture.md (alarm-history bullet appended).

Closes #267
2026-04-26 00:07:59 -04:00

1488 lines
73 KiB
C#

using System.Buffers.Binary;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
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 FocasAlarmProjection _alarmProjection;
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 readonly Dictionary<string, (string Host, string Field)> _diagnosticsNodesByName =
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"];
/// <summary>
/// Names of the five fixed-tree <c>Diagnostics/</c> child nodes per device — runtime
/// counters surfaced for operator visibility (issue #262). Order matters for
/// deterministic discovery output.
/// <list type="bullet">
/// <item><c>ReadCount</c> (Int64) — successful probe ticks since init</item>
/// <item><c>ReadFailureCount</c> (Int64) — failed probe ticks since init</item>
/// <item><c>LastErrorMessage</c> (String) — text of the last probe / read failure</item>
/// <item><c>LastSuccessfulRead</c> (DateTime) — UTC timestamp of the last good probe tick</item>
/// <item><c>ReconnectCount</c> (Int64) — wire reconnects observed since init</item>
/// </list>
/// </summary>
private static readonly string[] DiagnosticsFieldNames =
[
"ReadCount", "ReadFailureCount", "LastErrorMessage", "LastSuccessfulRead", "ReconnectCount",
];
public event EventHandler<DataChangeEventArgs>? OnDataChange;
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
/// <summary>
/// Per <see cref="IAlarmSource"/> — the projection raises history events through here
/// and a future PR's active-alarm poll will join the same channel (issue #267,
/// plan PR F3-a).
/// </summary>
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
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)));
_alarmProjection = new FocasAlarmProjection(
options: _options.AlarmProjection,
connectAsync: ConnectFirstDeviceAsync,
emit: args => OnAlarmEvent?.Invoke(this, args),
diagnosticPrefix: $"focas-alarm-{driverInstanceId}");
}
/// <summary>
/// Bridge for the alarm projection — returns the first device's connected
/// <see cref="IFocasClient"/> on demand. Multi-device alarm projection (one history
/// poll per CNC) is a follow-up; today the projection targets the primary device,
/// which is the only deployed shape per the F3-a plan.
/// </summary>
private async Task<IFocasClient?> ConnectFirstDeviceAsync(CancellationToken ct)
{
var device = _devices.Values.FirstOrDefault();
if (device is null) return null;
try { return await EnsureConnectedAsync(device, ct).ConfigureAwait(false); }
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch { return null; }
}
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, DIAG:1031, DIAG:280/2.");
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;
// Diagnostics/{ReadCount, ReadFailureCount, LastErrorMessage,
// LastSuccessfulRead, ReconnectCount} — runtime counters surfaced for
// operator visibility (issue #262). Permissive across all CNC series.
foreach (var field in DiagnosticsFieldNames)
_diagnosticsNodesByName[DiagnosticsReferenceFor(device.Options.HostAddress, field)] =
(device.Options.HostAddress, field);
}
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 _alarmProjection.DisposeAsync().ConfigureAwait(false);
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();
_diagnosticsNodesByName.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];
// PR F2-d (issue #266): pre-pass coalesces same-letter / same-path PMC byte reads
// in this batch into one wire call per group via FocasPmcCoalescer. Each entry in
// prefetched maps the original index to the byte slice + status from the range
// read; the per-tag loop below short-circuits to the cached slice when present
// and falls back to per-tag ReadAsync otherwise. Bit-addressed PMC tags
// (R100.3) coalesce on their parent byte (R100); the bit extract still happens
// in the existing decode path.
var prefetched = await PrefetchPmcRangesAsync(fullReferences, cancellationToken).ConfigureAwait(false);
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;
}
// Fixed-tree Diagnostics/ nodes — runtime counters maintained by the probe
// loop (issue #262). No wire call here.
if (_diagnosticsNodesByName.TryGetValue(reference, out var diagKey))
{
results[i] = ReadDiagnosticsField(diagKey.Host, diagKey.Field, 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}'.");
// Multi-path validation + dispatch (issue #264). PathId=1 is the back-compat
// default and skips the cnc_setpath call entirely; non-default paths are
// bounded against the device's cached PathCount and only switch the active
// path when it differs from the last one set on the session.
if (parsed.PathId > 1 && device.PathCount > 0 && parsed.PathId > device.PathCount)
{
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadOutOfRange, null, now);
continue;
}
if (parsed.PathId != 1 && device.LastSetPath != parsed.PathId)
{
await client.SetPathAsync(parsed.PathId, cancellationToken).ConfigureAwait(false);
device.LastSetPath = parsed.PathId;
}
// Coalesced PMC range fast path (issue #266) — if the prefetch pass already
// satisfied this byte range as part of a coalesced wire call, decode from
// the cached slice instead of issuing another per-tag ReadAsync.
if (prefetched is not null && prefetched.TryGetValue(i, out var slice))
{
if (slice.Status != FocasStatusMapper.Good)
{
results[i] = new DataValueSnapshot(null, slice.Status, null, now);
if (slice.Status != FocasStatusMapper.Good)
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
$"FOCAS status 0x{slice.Status:X8} reading {reference}");
continue;
}
var decoded = DecodePmcSlice(slice.Bytes, def.DataType, parsed.BitIndex);
results[i] = new DataValueSnapshot(decoded, FocasStatusMapper.Good, now, now);
_health = new DriverHealth(DriverState.Healthy, now, null);
continue;
}
var (value, status) = parsed.Kind == FocasAreaKind.Diagnostic
? await client.ReadDiagnosticAsync(
parsed.Number, parsed.BitIndex ?? 0, def.DataType, cancellationToken).ConfigureAwait(false)
: 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}'.");
if (parsed.PathId > 1 && device.PathCount > 0 && parsed.PathId > device.PathCount)
{
results[i] = new WriteResult(FocasStatusMapper.BadOutOfRange);
continue;
}
if (parsed.PathId != 1 && device.LastSetPath != parsed.PathId)
{
await client.SetPathAsync(parsed.PathId, cancellationToken).ConfigureAwait(false);
device.LastSetPath = parsed.PathId;
}
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));
// Fixed-tree Diagnostics/ subfolder — 5 read-only counters surfaced for
// operator visibility (issue #262). ReadCount / ReadFailureCount /
// ReconnectCount are Int64; LastErrorMessage is String;
// LastSuccessfulRead is DateTime. Permissive across CNC series — every
// device gets the same shape.
var diagnosticsFolder = deviceFolder.Folder("Diagnostics", "Diagnostics");
foreach (var field in DiagnosticsFieldNames)
{
var fullRef = DiagnosticsReferenceFor(device.HostAddress, field);
diagnosticsFolder.Variable(field, field, new DriverAttributeInfo(
FullName: fullRef,
DriverDataType: DiagnosticsFieldType(field),
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: false));
}
}
return Task.CompletedTask;
}
private static DriverDataType DiagnosticsFieldType(string field) => field switch
{
"ReadCount" or "ReadFailureCount" or "ReconnectCount" => DriverDataType.Int64,
"LastErrorMessage" => DriverDataType.String,
"LastSuccessfulRead" => DriverDataType.DateTime,
_ => DriverDataType.String,
};
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 string DiagnosticsReferenceFor(string hostAddress, string field) =>
$"{hostAddress}::Diagnostics/{field}";
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;
}
// ---- IAlarmSource (issue #267, plan PR F3-a) ----
/// <summary>
/// Subscribe to FOCAS alarm events. When
/// <see cref="FocasDriverOptions.AlarmProjection"/>'s mode is
/// <see cref="FocasAlarmProjectionMode.ActivePlusHistory"/>, the projection polls
/// <c>cnc_rdalmhistry</c> on connect + on the configured cadence and emits unseen
/// entries through <see cref="OnAlarmEvent"/> with the CNC's reported timestamp.
/// <see cref="FocasAlarmProjectionMode.ActiveOnly"/> (default) returns the handle
/// for capability negotiation but skips the history poll. The active-alarm poll
/// itself ships in a follow-up PR.
/// </summary>
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
=> _alarmProjection.SubscribeAsync(sourceNodeIds, cancellationToken);
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
=> _alarmProjection.UnsubscribeAsync(handle, cancellationToken);
public Task AcknowledgeAsync(
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken)
=> _alarmProjection.AcknowledgeAsync(acknowledgements, cancellationToken);
/// <summary>
/// Reset the alarm projection's dedup set. Called by the driver on reconnect so the
/// first poll after reconnect re-emits the ring buffer (acceptable per issue #267
/// since alarms are timestamped + clients can suppress repeats client-side).
/// </summary>
internal void ResetAlarmDedup() => _alarmProjection.ResetDedup();
// ---- 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;
string? failureMessage = null;
try
{
var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
success = await client.ProbeAsync(ct).ConfigureAwait(false);
if (success)
{
// Refresh figure-scaling cache once per session (issue #262). The
// increment system rarely changes mid-session; re-reading every probe
// tick would waste a wire call. Best-effort — null result leaves the
// previous good map in place.
if (state.FigureScaling is null)
{
var fig = await client.GetFigureScalingAsync(ct).ConfigureAwait(false);
if (fig is not null) state.FigureScaling = fig;
}
// 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 (Exception ex)
{
failureMessage = ex.Message;
/* connect-failure path already disposed + cleared the client */
}
// Diagnostics counters refreshed per probe tick (issue #262). Successful
// ticks bump ReadCount + LastSuccessfulRead; failed ticks bump
// ReadFailureCount + LastErrorMessage. The reconnect counter is bumped in
// EnsureConnectedAsync's connect path so a wedged probe doesn't double-count.
if (success)
{
Interlocked.Increment(ref state.ReadCount);
state.LastSuccessfulReadUtc = DateTime.UtcNow;
}
else
{
Interlocked.Increment(ref state.ReadFailureCount);
if (!string.IsNullOrEmpty(failureMessage))
state.LastErrorMessage = failureMessage;
}
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 DataValueSnapshot ReadDiagnosticsField(string hostAddress, string field, DateTime now)
{
if (!_devices.TryGetValue(hostAddress, out var device))
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
// Diagnostics counters are always Good — they're driver-internal state, not wire
// reads. LastSuccessfulRead surfaces DateTime.MinValue before the first probe
// tick rather than null because OPC UA's DateTime variant has no "unset" sentinel
// a generic client can interpret (issue #262).
object? value = field switch
{
"ReadCount" => Interlocked.Read(ref device.ReadCount),
"ReadFailureCount" => Interlocked.Read(ref device.ReadFailureCount),
"ReconnectCount" => Interlocked.Read(ref device.ReconnectCount),
"LastErrorMessage" => device.LastErrorMessage ?? string.Empty,
"LastSuccessfulRead" => device.LastSuccessfulReadUtc,
_ => null,
};
if (value is null)
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
return new DataValueSnapshot(value, FocasStatusMapper.Good, now, now);
}
/// <summary>
/// Pre-pass for <see cref="ReadAsync"/> that groups same-letter / same-path PMC byte
/// reads from <paramref name="fullReferences"/> into coalesced range reads (issue
/// #266). Returns a map from the original batch index to the byte slice + status the
/// wire returned for that tag's bytes; entries are absent for non-PMC tags + for tags
/// whose coalesced read failed at the connect / set-path / parse step (those fall
/// back to the existing per-tag dispatch in the main loop). Bit-addressed PMC tags
/// (e.g. <c>R100.3</c>) are coalesced on their parent byte; the bit extract is
/// deferred to <see cref="DecodePmcSlice"/> so the slice still satisfies them.
/// </summary>
private async Task<Dictionary<int, PmcSlice>?> PrefetchPmcRangesAsync(
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
{
// Build per-device PMC request lists. Skip non-PMC tags + tags that don't resolve.
Dictionary<string, List<(int Index, FocasAddress Parsed, FocasTagDefinition Def)>>? perDevice = null;
for (var i = 0; i < fullReferences.Count; i++)
{
var reference = fullReferences[i];
if (_statusNodesByName.ContainsKey(reference) ||
_productionNodesByName.ContainsKey(reference) ||
_modalNodesByName.ContainsKey(reference) ||
_overrideNodesByName.ContainsKey(reference) ||
_toolingNodesByName.ContainsKey(reference) ||
_offsetNodesByName.ContainsKey(reference) ||
_messagesNodesByName.ContainsKey(reference) ||
_currentBlockNodesByName.ContainsKey(reference) ||
_diagnosticsNodesByName.ContainsKey(reference))
continue;
if (!_tagsByName.TryGetValue(reference, out var def)) continue;
if (!_devices.TryGetValue(def.DeviceHostAddress, out _)) continue;
var parsed = FocasAddress.TryParse(def.Address);
if (parsed is null) continue;
if (parsed.Kind != FocasAreaKind.Pmc) continue;
if (string.IsNullOrEmpty(parsed.PmcLetter)) continue;
perDevice ??= new(StringComparer.OrdinalIgnoreCase);
if (!perDevice.TryGetValue(def.DeviceHostAddress, out var list))
perDevice[def.DeviceHostAddress] = list = new();
list.Add((i, parsed, def));
}
if (perDevice is null) return null;
var result = new Dictionary<int, PmcSlice>();
foreach (var (host, list) in perDevice)
{
// Single-tag PMC reads aren't worth coalescing — fall through to the per-tag
// path so we don't pay the connect/set-path overhead twice.
if (list.Count < 2) continue;
var device = _devices[host];
IFocasClient client;
try
{
client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) { throw; }
catch
{
continue; // per-tag path will surface BadCommunicationError per tag
}
// Build coalescer requests. Skip path-out-of-range tags here so the per-tag
// path can map them to BadOutOfRange consistently.
var pmcRequests = new List<PmcAddressRequest>(list.Count);
foreach (var (idx, parsed, def) in list)
{
if (parsed.PathId > 1 && device.PathCount > 0 && parsed.PathId > device.PathCount) continue;
pmcRequests.Add(new PmcAddressRequest(
parsed.PmcLetter!, parsed.PathId,
parsed.Number, FocasPmcCoalescer.ByteWidth(def.DataType), idx));
}
if (pmcRequests.Count < 2) continue;
var groups = FocasPmcCoalescer.Plan(pmcRequests);
foreach (var group in groups)
{
if (group.Members.Count < 2) continue; // single-member group — no benefit
if (group.PathId != 1 && device.LastSetPath != group.PathId)
{
try
{
await client.SetPathAsync(group.PathId, cancellationToken).ConfigureAwait(false);
device.LastSetPath = group.PathId;
}
catch (OperationCanceledException) { throw; }
catch { continue; }
}
byte[]? buffer; uint status;
try
{
(buffer, status) = await client.ReadPmcRangeAsync(
group.Letter, group.PathId, group.StartByte, group.ByteCount, cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException) { throw; }
catch
{
continue; // per-tag path will surface BadCommunicationError
}
foreach (var member in group.Members)
{
if (status != FocasStatusMapper.Good || buffer is null)
{
result[member.OriginalIndex] = new PmcSlice(Array.Empty<byte>(), status);
continue;
}
var slice = new byte[member.ByteWidth];
if (member.Offset + member.ByteWidth <= buffer.Length)
Array.Copy(buffer, member.Offset, slice, 0, member.ByteWidth);
result[member.OriginalIndex] = new PmcSlice(slice, FocasStatusMapper.Good);
}
}
}
return result.Count == 0 ? null : result;
}
/// <summary>
/// Decode the bytes of a coalesced PMC slice into the boxed managed value the
/// driver returns from <see cref="ReadAsync"/>. Mirrors the per-byte decode in
/// <see cref="FwlibFocasClient"/>'s <c>ReadPmc</c> + the bit-extract path so the
/// coalesced read is observably equivalent to a per-tag read.
/// </summary>
private static object? DecodePmcSlice(byte[] slice, FocasDataType type, int? bitIndex) => type switch
{
FocasDataType.Bit when bitIndex is int bi =>
slice.Length > 0 ? (object)((slice[0] & (1 << bi)) != 0) : null,
FocasDataType.Byte =>
slice.Length > 0 ? (object)(sbyte)slice[0] : null,
FocasDataType.Int16 =>
slice.Length >= 2 ? (object)BinaryPrimitives.ReadInt16LittleEndian(slice) : null,
FocasDataType.Int32 =>
slice.Length >= 4 ? (object)BinaryPrimitives.ReadInt32LittleEndian(slice) : null,
FocasDataType.Float32 =>
slice.Length >= 4 ? (object)BinaryPrimitives.ReadSingleLittleEndian(slice) : null,
FocasDataType.Float64 =>
slice.Length >= 8 ? (object)BinaryPrimitives.ReadDoubleLittleEndian(slice) : null,
_ => slice.Length > 0 ? (object)slice[0] : null,
};
/// <summary>One coalesced PMC slice — bytes the wire returned for one batch member + the status.</summary>
private readonly record struct PmcSlice(byte[] Bytes, uint Status);
/// <summary>
/// Apply <c>cnc_getfigure</c>-derived decimal scaling to a raw position value.
/// Returns <paramref name="raw"/> divided by <c>10^decimalPlaces</c> when the
/// device has a cached scaling entry for <paramref name="axisName"/> AND
/// <see cref="FocasFixedTreeOptions.ApplyFigureScaling"/> is on; otherwise
/// returns the raw value as a <c>double</c>. Forward-looking — surfaced for
/// future PRs that wire up <c>Axes/{name}/AbsolutePosition</c> etc. so they
/// don't need to re-derive the policy (issue #262).
/// </summary>
internal double ApplyFigureScaling(string hostAddress, string axisName, long raw)
{
if (!_options.FixedTree.ApplyFigureScaling) return raw;
if (!_devices.TryGetValue(hostAddress, out var device)) return raw;
if (device.FigureScaling is not { } map) return raw;
if (!map.TryGetValue(axisName, out var dec) || dec <= 0) return raw;
return raw / Math.Pow(10.0, dec);
}
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;
// Reconnect counter bumps before the connect call — a successful first connect
// counts as one "establishment" so the field is non-zero from session start
// (issue #262, mirrors the convention from the AbCip / TwinCAT diagnostics).
Interlocked.Increment(ref device.ReconnectCount);
device.Client ??= _clientFactory.Create();
try
{
await device.Client.ConnectAsync(device.ParsedAddress, _options.Timeout, ct).ConfigureAwait(false);
}
catch
{
device.Client.Dispose();
device.Client = null;
throw;
}
// Multi-path bootstrap (issue #264). cnc_rdpathnum runs once per session — the
// controller's path topology is fixed at boot. A reconnect resets the wire
// session's "last set path" so the next non-default-path read forces a fresh
// cnc_setpath; that's why LastSetPath is reset alongside PathCount here.
try
{
device.PathCount = await device.Client.GetPathCountAsync(ct).ConfigureAwait(false);
}
catch
{
device.PathCount = 1;
}
device.LastSetPath = 0;
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/&lt;field&gt;</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/&lt;field&gt;</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/&lt;field&gt;</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; }
/// <summary>
/// Cached per-axis decimal-place counts from <c>cnc_getfigure</c> (issue #262).
/// Populated once per session (the increment system rarely changes mid-run);
/// served by <see cref="FocasDriver.ApplyFigureScaling"/> when a future PR
/// surfaces position values that need scaling. Keys are axis names (or
/// fallback <c>"axis{n}"</c> until <c>cnc_rdaxisname</c> integration lands).
/// </summary>
public IReadOnlyDictionary<string, int>? FigureScaling { get; set; }
// Diagnostics counters per device — surfaced under Diagnostics/ subtree (issue
// #262). Public fields rather than properties so Interlocked.Increment can
// operate on them directly. Long-typed for the OPC UA Int64 surface.
public long ReadCount;
public long ReadFailureCount;
public long ReconnectCount;
public string? LastErrorMessage;
public DateTime LastSuccessfulReadUtc;
/// <summary>
/// CNC path topology cached at first successful connect via
/// <c>cnc_rdpathnum</c> (issue #264). 1 for single-path controllers; 2..N for
/// multi-path lathes / dual-turret machines. The driver validates per-tag
/// <c>FocasAddress.PathId</c> against this count + rejects with
/// <c>BadOutOfRange</c> when the tag points beyond what the CNC reports.
/// </summary>
public int PathCount { get; set; } = 1;
/// <summary>
/// Most recent path number set on the wire session via <c>cnc_setpath</c>,
/// or <c>0</c> when no path has been set yet (fresh session). The driver
/// skips redundant <c>cnc_setpath</c> calls when the tag's <c>PathId</c>
/// matches the last-set value; reconnects reset this to <c>0</c> so the
/// next non-default-path read forces a fresh switch (issue #264).
/// </summary>
public int LastSetPath { get; set; }
public void DisposeClient()
{
Client?.Dispose();
Client = null;
}
}
}