chore: organize solution into module folders (Core/Server/Drivers/Client/Tooling)

Group all 69 projects into category subfolders under src/ and tests/ so the
Rider Solution Explorer mirrors the module structure. Folders: Core, Server,
Drivers (with a nested Driver CLIs subfolder), Client, Tooling.

- Move every project folder on disk with git mv (history preserved as renames).
- Recompute relative paths in 57 .csproj files: cross-category ProjectReferences,
  the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external
  mxaccessgw refs in Driver.Galaxy and its test project.
- Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders.
- Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL,
  integration, install).

Build green (0 errors); unit tests pass. Docs left for a separate pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-17 01:55:28 -04:00
parent 69f02fed7f
commit a25593a9c6
1044 changed files with 365 additions and 343 deletions

View File

@@ -0,0 +1,95 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// <summary>
/// Parsed FOCAS address covering the three addressing spaces a driver touches:
/// <see cref="FocasAreaKind.Pmc"/> (letter + byte + optional bit — <c>X0.0</c>, <c>R100</c>,
/// <c>F20.3</c>), <see cref="FocasAreaKind.Parameter"/> (CNC parameter number —
/// <c>PARAM:1020</c>, <c>PARAM:1815/0</c> for bit 0), and <see cref="FocasAreaKind.Macro"/>
/// (macro variable number — <c>MACRO:100</c>, <c>MACRO:500</c>).
/// </summary>
/// <remarks>
/// PMC letters: <c>X/Y</c> (IO), <c>F/G</c> (signals between PMC + CNC), <c>R</c> (internal
/// relay), <c>D</c> (data table), <c>C</c> (counter), <c>K</c> (keep relay), <c>A</c>
/// (message display), <c>E</c> (extended relay), <c>T</c> (timer). Byte numbering is 0-based;
/// bit index when present is 07 and uses <c>.N</c> for PMC or <c>/N</c> for parameters.
/// </remarks>
public sealed record FocasAddress(
FocasAreaKind Kind,
string? PmcLetter,
int Number,
int? BitIndex)
{
public string Canonical => Kind switch
{
FocasAreaKind.Pmc => BitIndex is null
? $"{PmcLetter}{Number}"
: $"{PmcLetter}{Number}.{BitIndex}",
FocasAreaKind.Parameter => BitIndex is null
? $"PARAM:{Number}"
: $"PARAM:{Number}/{BitIndex}",
FocasAreaKind.Macro => $"MACRO:{Number}",
_ => $"?{Number}",
};
public static FocasAddress? TryParse(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
var src = value.Trim();
if (src.StartsWith("PARAM:", StringComparison.OrdinalIgnoreCase))
return ParseScoped(src["PARAM:".Length..], FocasAreaKind.Parameter, bitSeparator: '/');
if (src.StartsWith("MACRO:", StringComparison.OrdinalIgnoreCase))
return ParseScoped(src["MACRO:".Length..], FocasAreaKind.Macro, bitSeparator: null);
// PMC path: letter + digits + optional .bit
if (src.Length < 2 || !char.IsLetter(src[0])) return null;
var letter = src[0..1].ToUpperInvariant();
if (!IsValidPmcLetter(letter)) return null;
var remainder = src[1..];
int? bit = null;
var dotIdx = remainder.IndexOf('.');
if (dotIdx >= 0)
{
if (!int.TryParse(remainder[(dotIdx + 1)..], out var bitValue) || bitValue is < 0 or > 7)
return null;
bit = bitValue;
remainder = remainder[..dotIdx];
}
if (!int.TryParse(remainder, out var number) || number < 0) return null;
return new FocasAddress(FocasAreaKind.Pmc, letter, number, bit);
}
private static FocasAddress? ParseScoped(string body, FocasAreaKind kind, char? bitSeparator)
{
int? bit = null;
if (bitSeparator is char sep)
{
var slashIdx = body.IndexOf(sep);
if (slashIdx >= 0)
{
if (!int.TryParse(body[(slashIdx + 1)..], out var bitValue) || bitValue is < 0 or > 31)
return null;
bit = bitValue;
body = body[..slashIdx];
}
}
if (!int.TryParse(body, out var number) || number < 0) return null;
return new FocasAddress(kind, PmcLetter: null, number, bit);
}
private static bool IsValidPmcLetter(string letter) => letter switch
{
"X" or "Y" or "F" or "G" or "R" or "D" or "C" or "K" or "A" or "E" or "T" => true,
_ => false,
};
}
/// <summary>Addressing-space kinds the driver understands.</summary>
public enum FocasAreaKind
{
Pmc,
Parameter,
Macro,
}

View File

@@ -0,0 +1,195 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// <summary>
/// Polls each device's CNC active-alarm list via <see cref="IFocasClient.ReadAlarmsAsync"/>
/// on a timer and translates raise / clear transitions into <see cref="IAlarmSource"/>
/// events on the owning <see cref="FocasDriver"/>. One poll loop per subscription; the
/// loop fans out across every configured device and diffs the (<c>AlarmNumber</c>,
/// <c>Type</c>) keyed active-alarm set between ticks.
/// </summary>
/// <remarks>
/// FOCAS alarms are flat per session — the CNC exposes a single active-alarm list via
/// <c>cnc_rdalmmsg2</c>, not per-node structures the way Galaxy / AbCip ALMD do. So the
/// projection ignores <c>sourceNodeIds</c> at the member level: every alarm event is
/// raised with <c>SourceNodeId=device-host-address</c>. Callers that want per-device
/// filtering can pass the specific host addresses as <c>sourceNodeIds</c> and the
/// projection will skip devices not listed.
/// </remarks>
internal sealed class FocasAlarmProjection : IAsyncDisposable
{
private readonly FocasDriver _driver;
private readonly TimeSpan _pollInterval;
private readonly Dictionary<long, Subscription> _subs = new();
private readonly Lock _subsLock = new();
private long _nextId;
public FocasAlarmProjection(FocasDriver driver, TimeSpan pollInterval)
{
_driver = driver;
_pollInterval = pollInterval;
}
public Task<IAlarmSubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
{
var id = Interlocked.Increment(ref _nextId);
var handle = new FocasAlarmSubscriptionHandle(id);
var cts = new CancellationTokenSource();
// Empty filter = listen to every configured device. Otherwise only devices whose
// host address appears in sourceNodeIds are polled.
var filter = sourceNodeIds.Count == 0
? null
: new HashSet<string>(sourceNodeIds, StringComparer.OrdinalIgnoreCase);
var sub = new Subscription(handle, filter, cts);
lock (_subsLock) _subs[id] = sub;
sub.Loop = Task.Run(() => RunPollLoopAsync(sub, cts.Token), cts.Token);
return Task.FromResult<IAlarmSubscriptionHandle>(handle);
}
public async Task UnsubscribeAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
{
if (handle is not FocasAlarmSubscriptionHandle h) return;
Subscription? sub;
lock (_subsLock)
{
if (!_subs.Remove(h.Id, out sub)) return;
}
try { sub.Cts.Cancel(); } catch { }
try { await sub.Loop.ConfigureAwait(false); } catch { }
sub.Cts.Dispose();
}
/// <summary>
/// FOCAS has no ack wire call — the CNC clears alarms on its own when the underlying
/// condition resolves. Swallow the request so capability negotiation succeeds, rather
/// than surfacing a confusing "not supported" error to the operator.
/// </summary>
public Task AcknowledgeAsync(
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken) =>
Task.CompletedTask;
public async ValueTask DisposeAsync()
{
List<Subscription> snap;
lock (_subsLock) { snap = [.. _subs.Values]; _subs.Clear(); }
foreach (var sub in snap)
{
try { sub.Cts.Cancel(); } catch { }
try { await sub.Loop.ConfigureAwait(false); } catch { }
sub.Cts.Dispose();
}
}
/// <summary>
/// One poll-tick for one device. Diffs the new alarm list against the previous snapshot,
/// emits raise + clear events. Extracted so tests can drive a tick without spinning up
/// the full Task.Run loop.
/// </summary>
internal void Tick(Subscription sub, string deviceHostAddress, IReadOnlyList<FocasActiveAlarm> current)
{
var prev = sub.LastByDevice.GetValueOrDefault(deviceHostAddress) ?? [];
var nowKeys = current.Select(a => AlarmKey(a)).ToHashSet();
var prevKeys = prev.Select(a => AlarmKey(a)).ToHashSet();
foreach (var a in current)
{
if (prevKeys.Contains(AlarmKey(a))) continue;
_driver.InvokeAlarmEvent(new AlarmEventArgs(
sub.Handle,
SourceNodeId: deviceHostAddress,
ConditionId: $"{deviceHostAddress}#{AlarmKey(a)}",
AlarmType: MapAlarmType(a.Type),
Message: a.Message,
Severity: MapSeverity(a.Type),
SourceTimestampUtc: DateTime.UtcNow));
}
foreach (var a in prev)
{
if (nowKeys.Contains(AlarmKey(a))) continue;
_driver.InvokeAlarmEvent(new AlarmEventArgs(
sub.Handle,
SourceNodeId: deviceHostAddress,
ConditionId: $"{deviceHostAddress}#{AlarmKey(a)}",
AlarmType: MapAlarmType(a.Type),
Message: $"{a.Message} (cleared)",
Severity: MapSeverity(a.Type),
SourceTimestampUtc: DateTime.UtcNow));
}
sub.LastByDevice[deviceHostAddress] = [.. current];
}
private async Task RunPollLoopAsync(Subscription sub, CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try
{
foreach (var (host, alarms) in await _driver.ReadActiveAlarmsAcrossDevicesAsync(sub.DeviceFilter, ct).ConfigureAwait(false))
{
Tick(sub, host, alarms);
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
catch { /* per-tick failures are non-fatal — next tick retries */ }
try { await Task.Delay(_pollInterval, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { break; }
}
}
private static string AlarmKey(FocasActiveAlarm a) => $"{a.Type}:{a.AlarmNumber}";
/// <summary>Map FOCAS type to a human-readable category; falls back to the numeric type.</summary>
internal static string MapAlarmType(short type) => type switch
{
FocasAlarmType.Parameter => "Parameter",
FocasAlarmType.PulseCode => "PulseCode",
FocasAlarmType.Overtravel => "Overtravel",
FocasAlarmType.Overheat => "Overheat",
FocasAlarmType.Servo => "Servo",
FocasAlarmType.DataIo => "DataIo",
FocasAlarmType.MemoryCheck => "MemoryCheck",
FocasAlarmType.MacroAlarm => "MacroAlarm",
_ => $"Type{type}",
};
/// <summary>
/// Project FOCAS alarm types into the driver-agnostic 4-band severity. Overtravel /
/// Servo / Emergency-equivalents are Critical; Parameter + Macro are Medium; rest land
/// at High (everything else on a CNC is safety-relevant).
/// </summary>
internal static AlarmSeverity MapSeverity(short type) => type switch
{
FocasAlarmType.Overtravel => AlarmSeverity.Critical,
FocasAlarmType.Servo => AlarmSeverity.Critical,
FocasAlarmType.PulseCode => AlarmSeverity.Critical,
FocasAlarmType.Parameter => AlarmSeverity.Medium,
FocasAlarmType.MacroAlarm => AlarmSeverity.Medium,
_ => AlarmSeverity.High,
};
internal sealed class Subscription(
FocasAlarmSubscriptionHandle handle,
HashSet<string>? deviceFilter,
CancellationTokenSource cts)
{
public FocasAlarmSubscriptionHandle Handle { get; } = handle;
public HashSet<string>? DeviceFilter { get; } = deviceFilter;
public CancellationTokenSource Cts { get; } = cts;
public Task Loop { get; set; } = Task.CompletedTask;
public Dictionary<string, IReadOnlyList<FocasActiveAlarm>> LastByDevice { get; } =
new(StringComparer.OrdinalIgnoreCase);
}
}
/// <summary>Handle returned by <see cref="FocasAlarmProjection.SubscribeAsync"/>.</summary>
public sealed record FocasAlarmSubscriptionHandle(long Id) : IAlarmSubscriptionHandle
{
public string DiagnosticId => $"focas-alarm-sub-{Id}";
}

View File

@@ -0,0 +1,139 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// <summary>
/// Documented-API capability matrix — per CNC series, what ranges each
/// <see cref="FocasAreaKind"/> supports. Authoritative source for the driver's
/// pre-flight validation in <see cref="FocasDriver.InitializeAsync"/>.
/// </summary>
/// <remarks>
/// <para>Ranges come from the Fanuc FOCAS Developer Kit documentation matrix
/// (see <c>docs/v2/focas-version-matrix.md</c> for the authoritative copy with
/// per-function citations). Numbers chosen to match what the FOCAS library
/// accepts — a read against an address outside the documented range returns
/// <c>EW_NUMBER</c> or <c>EW_PARAM</c> at the wire, which this driver maps to
/// BadOutOfRange. Catching at init time surfaces the mismatch as a config
/// error before any session is opened.</para>
/// <para><see cref="FocasCncSeries.Unknown"/> is treated permissively: every
/// address passes validation. Pre-matrix configs don't break on upgrade; new
/// deployments are encouraged to declare a series in the device options.</para>
/// </remarks>
public static class FocasCapabilityMatrix
{
/// <summary>
/// Check whether <paramref name="address"/> is accepted by a CNC of
/// <paramref name="series"/>. Returns <c>null</c> on pass + a failure reason
/// on reject — the driver surfaces the reason string verbatim when failing
/// <c>InitializeAsync</c> so operators see the specific out-of-range without
/// guessing.
/// </summary>
public static string? Validate(FocasCncSeries series, FocasAddress address)
{
if (series == FocasCncSeries.Unknown) return null;
return address.Kind switch
{
FocasAreaKind.Macro => ValidateMacro(series, address.Number),
FocasAreaKind.Parameter => ValidateParameter(series, address.Number),
FocasAreaKind.Pmc => ValidatePmc(series, address.PmcLetter, address.Number),
_ => null,
};
}
/// <summary>Macro variable number accepted by a CNC series. Cites
/// <c>cnc_rdmacro</c>/<c>cnc_wrmacro</c> in the Developer Kit.</summary>
internal static (int min, int max) MacroRange(FocasCncSeries series) => series switch
{
// Common macros 1-33 + 100-199 + 500-999 universally; extended 10000+ only on
// higher-end series. Using the extended ceiling per series per DevKit notes.
FocasCncSeries.Sixteen_i => (0, 999),
FocasCncSeries.Zero_i_D => (0, 999),
FocasCncSeries.Zero_i_F or
FocasCncSeries.Zero_i_MF or
FocasCncSeries.Zero_i_TF => (0, 9999),
FocasCncSeries.Thirty_i or
FocasCncSeries.ThirtyOne_i or
FocasCncSeries.ThirtyTwo_i => (0, 99999),
FocasCncSeries.PowerMotion_i => (0, 999),
_ => (0, int.MaxValue),
};
/// <summary>Parameter number accepted; from <c>cnc_rdparam</c>/<c>cnc_wrparam</c>.
/// Ranges reflect the highest-numbered parameter documented per series.</summary>
internal static (int min, int max) ParameterRange(FocasCncSeries series) => series switch
{
FocasCncSeries.Sixteen_i => (0, 9999),
FocasCncSeries.Zero_i_D or
FocasCncSeries.Zero_i_F or
FocasCncSeries.Zero_i_MF or
FocasCncSeries.Zero_i_TF => (0, 14999),
FocasCncSeries.Thirty_i or
FocasCncSeries.ThirtyOne_i or
FocasCncSeries.ThirtyTwo_i => (0, 29999),
FocasCncSeries.PowerMotion_i => (0, 29999),
_ => (0, int.MaxValue),
};
/// <summary>PMC letters accepted per series. Legacy controllers omit F/M/C
/// signal groups that 30i-family ladder programs use.</summary>
internal static IReadOnlySet<string> PmcLetters(FocasCncSeries series) => series switch
{
FocasCncSeries.Sixteen_i => new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "X", "Y", "R", "D" },
FocasCncSeries.Zero_i_D => new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "X", "Y", "R", "D", "E", "A" },
FocasCncSeries.Zero_i_F or
FocasCncSeries.Zero_i_MF or
FocasCncSeries.Zero_i_TF => new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "X", "Y", "F", "G", "R", "D", "E", "A", "M", "C" },
FocasCncSeries.Thirty_i or
FocasCncSeries.ThirtyOne_i or
FocasCncSeries.ThirtyTwo_i => new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "X", "Y", "F", "G", "R", "D", "E", "A", "M", "C", "K", "T" },
FocasCncSeries.PowerMotion_i => new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "X", "Y", "R", "D" },
_ => new HashSet<string>(StringComparer.OrdinalIgnoreCase),
};
/// <summary>PMC address-number ceiling per series. Multiplied by 8 to get bit
/// count since PMC addresses are byte-addressed on read + bit-addressed on
/// write — FocasAddress carries the bit separately.</summary>
internal static int PmcMaxNumber(FocasCncSeries series) => series switch
{
FocasCncSeries.Sixteen_i => 999,
FocasCncSeries.Zero_i_D => 1999,
FocasCncSeries.Zero_i_F or
FocasCncSeries.Zero_i_MF or
FocasCncSeries.Zero_i_TF => 9999,
FocasCncSeries.Thirty_i or
FocasCncSeries.ThirtyOne_i or
FocasCncSeries.ThirtyTwo_i => 59999,
FocasCncSeries.PowerMotion_i => 1999,
_ => int.MaxValue,
};
private static string? ValidateMacro(FocasCncSeries series, int number)
{
var (min, max) = MacroRange(series);
return (number < min || number > max)
? $"Macro variable #{number} is outside the documented range [{min}, {max}] for {series}."
: null;
}
private static string? ValidateParameter(FocasCncSeries series, int number)
{
var (min, max) = ParameterRange(series);
return (number < min || number > max)
? $"Parameter #{number} is outside the documented range [{min}, {max}] for {series}."
: null;
}
private static string? ValidatePmc(FocasCncSeries series, string? letter, int number)
{
if (string.IsNullOrEmpty(letter)) return "PMC address is missing its letter prefix.";
var letters = PmcLetters(series);
if (!letters.Contains(letter))
{
var letterList = string.Join(", ", letters);
return $"PMC letter '{letter}' is not supported on {series}. Accepted: {{{letterList}}}.";
}
var max = PmcMaxNumber(series);
return number > max
? $"PMC address {letter}{number} is outside the documented range [0, {max}] for {series}."
: null;
}
}

View File

@@ -0,0 +1,47 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// <summary>
/// Fanuc CNC controller series. Used by <see cref="FocasCapabilityMatrix"/> to
/// gate which FOCAS addresses + value ranges the driver accepts against a given
/// CNC — the FOCAS API surface varies meaningfully between series (macro ranges,
/// PMC address letters, parameter numbers). A tag reference that's valid on a
/// 30i might be out-of-range on an 0i-MF; validating at driver
/// <c>InitializeAsync</c> time surfaces the mismatch as a fast config error
/// instead of a runtime read failure after the server's already running.
/// </summary>
/// <remarks>
/// <para>Values chosen from the Fanuc FOCAS Developer Kit documented series
/// matrix. Add a new entry + a row to <see cref="FocasCapabilityMatrix"/> when
/// a new controller is targeted — the driver will refuse the device until both
/// sides of the enum are filled in.</para>
/// <para>Defaults to <see cref="Unknown"/> when the operator doesn't specify;
/// the capability matrix treats Unknown as permissive (no range validation,
/// same as pre-matrix behaviour) so old configs don't break on upgrade.</para>
/// </remarks>
public enum FocasCncSeries
{
/// <summary>No series declared; capability matrix is permissive (legacy behaviour).</summary>
Unknown = 0,
/// <summary>Series 0i-D — compact CNC, narrow macro + PMC ranges.</summary>
Zero_i_D,
/// <summary>Series 0i-F — successor to 0i-D; widened macro range, added Plus variant.</summary>
Zero_i_F,
/// <summary>Series 0i-MF / 0i-MF Plus — machining-centre variants of 0i-F.</summary>
Zero_i_MF,
/// <summary>Series 0i-TF / 0i-TF Plus — turning-centre variants of 0i-F.</summary>
Zero_i_TF,
/// <summary>Series 16i / 18i / 21i — mid-range legacy; narrow ranges, limited PMC letters.</summary>
Sixteen_i,
/// <summary>Series 30i — high-end; widest macro / PMC / parameter ranges.</summary>
Thirty_i,
/// <summary>Series 31i — subset of 30i (fewer axes, same FOCAS surface).</summary>
ThirtyOne_i,
/// <summary>Series 32i — compact 30i variant.</summary>
ThirtyTwo_i,
/// <summary>Power Motion i — motion-control variant; atypical macro coverage.</summary>
PowerMotion_i,
}

View File

@@ -0,0 +1,39 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// <summary>
/// FOCAS atomic data types. Narrower than Logix/IEC — FANUC CNCs expose mostly integer +
/// floating-point data with no UDT concept; macro variables are double-precision floats
/// and PMC reads return byte / signed word / signed dword.
/// </summary>
public enum FocasDataType
{
/// <summary>Single bit (PMC bit, or bit within a CNC parameter).</summary>
Bit,
/// <summary>8-bit signed byte (PMC 1-byte read).</summary>
Byte,
/// <summary>16-bit signed word (PMC 2-byte read, or CNC parameter as short).</summary>
Int16,
/// <summary>32-bit signed int (PMC 4-byte read, or CNC parameter as int).</summary>
Int32,
/// <summary>32-bit IEEE-754 float (rare; some CNC macro variables).</summary>
Float32,
/// <summary>64-bit IEEE-754 double (most macro variables are double-precision).</summary>
Float64,
/// <summary>ASCII string (alarm text, parameter names, some PMC string areas).</summary>
String,
}
public static class FocasDataTypeExtensions
{
public static DriverDataType ToDriverDataType(this FocasDataType t) => t switch
{
FocasDataType.Bit => DriverDataType.Boolean,
FocasDataType.Byte or FocasDataType.Int16 or FocasDataType.Int32 => DriverDataType.Int32,
FocasDataType.Float32 => DriverDataType.Float32,
FocasDataType.Float64 => DriverDataType.Float64,
FocasDataType.String => DriverDataType.String,
_ => DriverDataType.Int32,
};
}

View File

@@ -0,0 +1,933 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// <summary>
/// FOCAS driver for Fanuc CNC controllers (FS 0i / 16i / 18i / 21i / 30i / 31i / 32i / Series
/// 35i / Power Mate i). Talks to the CNC via the Fanuc FOCAS/2 FWLIB protocol through an
/// <see cref="IFocasClient"/> the deployment supplies — FWLIB itself is Fanuc-proprietary
/// and cannot be redistributed.
/// </summary>
/// <remarks>
/// PR 1 ships <see cref="IDriver"/> only; read / write / discover / subscribe / probe / host-
/// resolver capabilities land in PRs 2 and 3. The <see cref="IFocasClient"/> abstraction
/// shipped here lets PR 2 onward stay license-clean — all tests run against a fake client
/// + the default <see cref="UnimplementedFocasClientFactory"/> makes misconfigured servers
/// fail fast.
/// </remarks>
public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, IDisposable, IAsyncDisposable
{
private readonly FocasDriverOptions _options;
private readonly string _driverInstanceId;
private readonly IFocasClientFactory _clientFactory;
private readonly PollGroupEngine _poll;
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, FocasTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
private FocasAlarmProjection? _alarmProjection;
private DriverHealth _health = new(DriverState.Unknown, null, null);
public event EventHandler<DataChangeEventArgs>? OnDataChange;
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
public FocasDriver(FocasDriverOptions options, string driverInstanceId,
IFocasClientFactory? clientFactory = null)
{
ArgumentNullException.ThrowIfNull(options);
_options = options;
_driverInstanceId = driverInstanceId;
_clientFactory = clientFactory ?? new Wire.WireFocasClientFactory();
_poll = new PollGroupEngine(
reader: ReadAsync,
onChange: (handle, tagRef, snapshot) =>
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)));
}
public string DriverInstanceId => _driverInstanceId;
public string DriverType => "FOCAS";
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
_health = new DriverHealth(DriverState.Initializing, null, null);
try
{
foreach (var device in _options.Devices)
{
var addr = FocasHostAddress.TryParse(device.HostAddress)
?? throw new InvalidOperationException(
$"FOCAS device has invalid HostAddress '{device.HostAddress}' — expected 'focas://{{ip}}[:{{port}}]'.");
_devices[device.HostAddress] = new DeviceState(addr, device);
}
// Pre-flight: validate every tag's address against the declared CNC
// series so misconfigured addresses fail at init (clear config error)
// instead of producing BadOutOfRange on every read at runtime.
// Series=Unknown short-circuits the matrix; pre-matrix configs stay permissive.
foreach (var tag in _options.Tags)
{
var parsed = FocasAddress.TryParse(tag.Address)
?? throw new InvalidOperationException(
$"FOCAS tag '{tag.Name}' has invalid Address '{tag.Address}'. " +
$"Expected forms: R100, R100.3, PARAM:1815/0, MACRO:500.");
if (_devices.TryGetValue(tag.DeviceHostAddress, out var device)
&& FocasCapabilityMatrix.Validate(device.Options.Series, parsed) is { } reason)
{
throw new InvalidOperationException(
$"FOCAS tag '{tag.Name}' ({tag.Address}) rejected by capability matrix: {reason}");
}
_tagsByName[tag.Name] = tag;
}
if (_options.Probe.Enabled)
{
foreach (var state in _devices.Values)
{
state.ProbeCts = new CancellationTokenSource();
var ct = state.ProbeCts.Token;
_ = Task.Run(() => ProbeLoopAsync(state, ct), ct);
}
}
if (_options.HandleRecycle.Enabled)
{
foreach (var state in _devices.Values)
{
state.RecycleCts = new CancellationTokenSource();
var ct = state.RecycleCts.Token;
_ = Task.Run(() => RecycleLoopAsync(state, ct), ct);
}
}
if (_options.AlarmProjection.Enabled)
_alarmProjection = new FocasAlarmProjection(this, _options.AlarmProjection.PollInterval);
if (_options.FixedTree.Enabled)
{
foreach (var state in _devices.Values)
{
state.FixedTreeCts = new CancellationTokenSource();
var ct = state.FixedTreeCts.Token;
_ = Task.Run(() => FixedTreeLoopAsync(state, ct), ct);
}
}
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
}
catch (Exception ex)
{
_health = new DriverHealth(DriverState.Faulted, null, ex.Message);
throw;
}
return Task.CompletedTask;
}
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
}
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
await _poll.DisposeAsync().ConfigureAwait(false);
if (_alarmProjection is { } proj)
{
await proj.DisposeAsync().ConfigureAwait(false);
_alarmProjection = null;
}
foreach (var state in _devices.Values)
{
try { state.ProbeCts?.Cancel(); } catch { }
state.ProbeCts?.Dispose();
state.ProbeCts = null;
try { state.RecycleCts?.Cancel(); } catch { }
state.RecycleCts?.Dispose();
state.RecycleCts = null;
try { state.FixedTreeCts?.Cancel(); } catch { }
state.FixedTreeCts?.Dispose();
state.FixedTreeCts = null;
state.DisposeClient();
}
_devices.Clear();
_tagsByName.Clear();
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
}
public DriverHealth GetHealth() => _health;
public long GetMemoryFootprint() => 0;
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
internal int DeviceCount => _devices.Count;
internal DeviceState? GetDeviceState(string hostAddress) =>
_devices.TryGetValue(hostAddress, out var s) ? s : null;
// ---- IReadable ----
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(fullReferences);
var now = DateTime.UtcNow;
var results = new DataValueSnapshot[fullReferences.Count];
for (var i = 0; i < fullReferences.Count; i++)
{
var reference = fullReferences[i];
// Fixed-tree T1 — fixed-tree references are synthesized from the cached
// dynamic snapshot + sysinfo; no P/Invoke per Read since the poll loop
// already fires them on cadence.
if (_options.FixedTree.Enabled && TryReadFixedTree(reference, now) is { } fx)
{
results[i] = fx;
continue;
}
if (!_tagsByName.TryGetValue(reference, out var def))
{
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
continue;
}
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
{
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
continue;
}
try
{
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
var parsed = FocasAddress.TryParse(def.Address)
?? throw new InvalidOperationException($"FOCAS tag '{def.Name}' has malformed Address '{def.Address}'.");
var (value, status) = await client.ReadAsync(parsed, def.DataType, cancellationToken).ConfigureAwait(false);
results[i] = new DataValueSnapshot(value, status, now, now);
if (status == FocasStatusMapper.Good)
_health = new DriverHealth(DriverState.Healthy, now, null);
else
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
$"FOCAS status 0x{status:X8} reading {reference}");
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
}
}
return results;
}
// ---- IWritable ----
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(writes);
var results = new WriteResult[writes.Count];
for (var i = 0; i < writes.Count; i++)
{
var w = writes[i];
if (!_tagsByName.TryGetValue(w.FullReference, out var def))
{
results[i] = new WriteResult(FocasStatusMapper.BadNodeIdUnknown);
continue;
}
if (!def.Writable)
{
results[i] = new WriteResult(FocasStatusMapper.BadNotWritable);
continue;
}
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
{
results[i] = new WriteResult(FocasStatusMapper.BadNodeIdUnknown);
continue;
}
try
{
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
var parsed = FocasAddress.TryParse(def.Address)
?? throw new InvalidOperationException($"FOCAS tag '{def.Name}' has malformed Address '{def.Address}'.");
var status = await client.WriteAsync(parsed, def.DataType, w.Value, cancellationToken).ConfigureAwait(false);
results[i] = new WriteResult(status);
}
catch (OperationCanceledException) { throw; }
catch (NotSupportedException nse)
{
results[i] = new WriteResult(FocasStatusMapper.BadNotSupported);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
}
catch (Exception ex) when (ex is FormatException or InvalidCastException)
{
results[i] = new WriteResult(FocasStatusMapper.BadTypeMismatch);
}
catch (OverflowException)
{
results[i] = new WriteResult(FocasStatusMapper.BadOutOfRange);
}
catch (Exception ex)
{
results[i] = new WriteResult(FocasStatusMapper.BadCommunicationError);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
}
}
return results;
}
// ---- ITagDiscovery ----
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(builder);
var root = builder.Folder("FOCAS", "FOCAS");
foreach (var device in _options.Devices)
{
var label = device.DeviceName ?? device.HostAddress;
var deviceFolder = root.Folder(device.HostAddress, label);
// Fixed-tree T1 — Identity + Axes subtrees, populated once per session
// from cnc_sysinfo + cnc_rdaxisname at init time and kept in DeviceState.
if (_options.FixedTree.Enabled
&& _devices.TryGetValue(device.HostAddress, out var state)
&& state.FixedTreeCache is { } cache)
{
var identity = deviceFolder.Folder("Identity", "Identity");
EmitIdentityVariable(identity, device.HostAddress, "SeriesNumber", FocasDriverDataType.String);
EmitIdentityVariable(identity, device.HostAddress, "Version", FocasDriverDataType.String);
EmitIdentityVariable(identity, device.HostAddress, "MaxAxes", FocasDriverDataType.Int32);
EmitIdentityVariable(identity, device.HostAddress, "CncType", FocasDriverDataType.String);
EmitIdentityVariable(identity, device.HostAddress, "MtType", FocasDriverDataType.String);
EmitIdentityVariable(identity, device.HostAddress, "AxisCount", FocasDriverDataType.Int32);
var axesFolder = deviceFolder.Folder("Axes", "Axes");
foreach (var axis in cache.Axes)
{
var axisFolder = axesFolder.Folder(axis.Display, axis.Display);
EmitAxisVariable(axisFolder, device.HostAddress, axis.Display, "AbsolutePosition");
EmitAxisVariable(axisFolder, device.HostAddress, axis.Display, "MachinePosition");
EmitAxisVariable(axisFolder, device.HostAddress, axis.Display, "RelativePosition");
EmitAxisVariable(axisFolder, device.HostAddress, axis.Display, "DistanceToGo");
if (cache.Capabilities.ServoLoad)
EmitAxisVariable(axisFolder, device.HostAddress, axis.Display, "ServoLoad");
}
EmitAxisVariable(axesFolder, device.HostAddress, "FeedRate", "Actual");
EmitAxisVariable(axesFolder, device.HostAddress, "SpindleSpeed", "Actual");
// Spindle subtree — one folder per discovered spindle, suppressed
// entirely on series that don't export cnc_rdspdlname. Per-spindle
// Load + MaxRpm each gated on their own capability probe.
if (cache.Capabilities.Spindles)
{
var spindleRoot = deviceFolder.Folder("Spindle", "Spindle");
for (var i = 0; i < cache.Spindles.Count; i++)
{
var s = cache.Spindles[i];
var name = string.IsNullOrEmpty(s.Display) ? $"S{i + 1}" : s.Display;
var spindleFolder = spindleRoot.Folder(name, name);
if (cache.Capabilities.SpindleLoad)
EmitFixedVariable(spindleFolder, device.HostAddress, $"Spindle/{name}", "Load", DriverDataType.Int32);
if (cache.Capabilities.SpindleMaxRpm && i < cache.SpindleMaxRpms.Count)
EmitFixedVariable(spindleFolder, device.HostAddress, $"Spindle/{name}", "MaxRpm", DriverDataType.Int32);
}
}
// Fixed-tree T2 — Program + OperationMode subtrees (gated on capability).
if (cache.Capabilities.ProgramInfo)
{
var program = deviceFolder.Folder("Program", "Program");
EmitFixedVariable(program, device.HostAddress, "Program", "Name", DriverDataType.String);
EmitFixedVariable(program, device.HostAddress, "Program", "ONumber", DriverDataType.Int32);
EmitFixedVariable(program, device.HostAddress, "Program", "Number", DriverDataType.Int32);
EmitFixedVariable(program, device.HostAddress, "Program", "MainNumber", DriverDataType.Int32);
EmitFixedVariable(program, device.HostAddress, "Program", "Sequence", DriverDataType.Int32);
EmitFixedVariable(program, device.HostAddress, "Program", "BlockCount", DriverDataType.Int32);
var opMode = deviceFolder.Folder("OperationMode", "OperationMode");
EmitFixedVariable(opMode, device.HostAddress, "OperationMode", "Mode", DriverDataType.Int32);
EmitFixedVariable(opMode, device.HostAddress, "OperationMode", "ModeText", DriverDataType.String);
}
// Fixed-tree T3 — Timers subtree (power-on / operating / cutting / cycle).
if (cache.Capabilities.Timers)
{
var timers = deviceFolder.Folder("Timers", "Timers");
EmitFixedVariable(timers, device.HostAddress, "Timers", "PowerOnSeconds", DriverDataType.Float64);
EmitFixedVariable(timers, device.HostAddress, "Timers", "OperatingSeconds", DriverDataType.Float64);
EmitFixedVariable(timers, device.HostAddress, "Timers", "CuttingSeconds", DriverDataType.Float64);
EmitFixedVariable(timers, device.HostAddress, "Timers", "CycleSeconds", DriverDataType.Float64);
}
}
var tagsForDevice = _options.Tags.Where(t =>
string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
foreach (var tag in tagsForDevice)
{
deviceFolder.Variable(tag.Name, tag.Name, new DriverAttributeInfo(
FullName: tag.Name,
DriverDataType: tag.DataType.ToDriverDataType(),
IsArray: false,
ArrayDim: null,
SecurityClass: tag.Writable
? SecurityClassification.Operate
: SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: tag.WriteIdempotent));
}
}
return Task.CompletedTask;
}
private enum FocasDriverDataType { String, Int32, Float64 }
private static void EmitIdentityVariable(
IAddressSpaceBuilder folder, string deviceHost, string field, FocasDriverDataType type)
{
var fullName = FixedTreeReference(deviceHost, $"Identity/{field}");
folder.Variable(field, field, new DriverAttributeInfo(
FullName: fullName,
DriverDataType: type switch
{
FocasDriverDataType.Int32 => DriverDataType.Int32,
FocasDriverDataType.Float64 => DriverDataType.Float64,
_ => DriverDataType.String,
},
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: false));
}
private static void EmitAxisVariable(
IAddressSpaceBuilder folder, string deviceHost, string axisName, string field)
{
var fullName = FixedTreeReference(deviceHost, $"Axes/{axisName}/{field}");
folder.Variable(field, field, new DriverAttributeInfo(
FullName: fullName,
DriverDataType: DriverDataType.Float64,
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: false));
}
/// <summary>
/// Emit a variable under a named fixed-tree folder (Program, OperationMode,
/// …). Full-reference shape is <c>{deviceHost}/{folderPath}/{field}</c>.
/// </summary>
private static void EmitFixedVariable(
IAddressSpaceBuilder folder, string deviceHost, string folderPath,
string field, DriverDataType type)
{
var fullName = FixedTreeReference(deviceHost, $"{folderPath}/{field}");
folder.Variable(field, field, new DriverAttributeInfo(
FullName: fullName,
DriverDataType: type,
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: false));
}
/// <summary>
/// Canonical full-reference shape for a fixed-tree node. Keeps the device
/// host as a prefix so multi-device configs don't collide, and the rest is
/// the path inside the tree. Matches what poll-loop snapshots publish +
/// what <see cref="ReadAsync"/> looks up.
/// </summary>
internal static string FixedTreeReference(string deviceHost, string path) =>
$"{deviceHost}/{path}";
// ---- ISubscribable (polling overlay via shared engine) ----
public Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) =>
Task.FromResult(_poll.Subscribe(fullReferences, publishingInterval));
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
{
_poll.Unsubscribe(handle);
return Task.CompletedTask;
}
// ---- IHostConnectivityProbe ----
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses() =>
[.. _devices.Values.Select(s => new HostConnectivityStatus(s.Options.HostAddress, s.HostState, s.HostStateChangedUtc))];
private async Task ProbeLoopAsync(DeviceState state, CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
var success = false;
try
{
var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
success = await client.ProbeAsync(ct).ConfigureAwait(false);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
catch { /* connect-failure path already disposed + cleared the client */ }
TransitionDeviceState(state, success ? HostState.Running : HostState.Stopped);
try { await Task.Delay(_options.Probe.Interval, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { break; }
}
}
/// <summary>
/// Per-device fixed-tree poll loop. First tick resolves sysinfo + axis names
/// (once) so <see cref="DiscoverAsync"/> can render the subtree on its next
/// invocation; every tick thereafter fires a <c>cnc_rddynamic2</c> per axis
/// and publishes OnDataChange for the axis positions + feed rate + spindle
/// speed.
/// </summary>
private async Task FixedTreeLoopAsync(DeviceState state, CancellationToken ct)
{
// Bootstrap: identity + axis names + per-optional-API capability probe.
// Each optional call is attempted once; failures (EW_FUNC / EW_NOOPT / EW_VERSION)
// record the capability as unsupported and suppress the corresponding nodes
// in DiscoverAsync + the poll loop.
while (!ct.IsCancellationRequested && state.FixedTreeCache is null)
{
try
{
var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
var sys = await client.GetSysInfoAsync(ct).ConfigureAwait(false);
var axes = await client.GetAxisNamesAsync(ct).ConfigureAwait(false);
// Optional-API probes — each returns empty / throws when unsupported.
var spindles = await SafeProbe(() => client.GetSpindleNamesAsync(ct), []);
var spindleMaxRpms = await SafeProbe(() => client.GetSpindleMaxRpmsAsync(ct), []);
var servoLoads = await SafeProbe(() => client.GetServoLoadsAsync(ct), []);
var programInfo = await SafeTryProbe(() => client.GetProgramInfoAsync(ct));
var timer = await SafeTryProbe(() => client.GetTimerAsync(FocasTimerKind.PowerOn, ct));
var spindleLoad = await SafeProbe(() => client.GetSpindleLoadsAsync(ct), []);
var caps = new FocasFixedTreeCapabilities(
Spindles: spindles.Count > 0,
SpindleLoad: spindleLoad.Count > 0,
SpindleMaxRpm: spindleMaxRpms.Count > 0,
ServoLoad: servoLoads.Count > 0,
ProgramInfo: programInfo is not null,
Timers: timer is not null);
state.FixedTreeCache = new FocasFixedTreeCache(
sys, [.. axes], [.. spindles], [.. spindleMaxRpms], caps);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }
catch
{
try { await Task.Delay(TimeSpan.FromSeconds(2), ct).ConfigureAwait(false); }
catch (OperationCanceledException) { return; }
}
}
// Prime the spindle-loads cache from bootstrap if supported — avoids a
// "tree is there but reads say BadNodeIdUnknown" window on startup.
if (state.FixedTreeCache?.Capabilities is { SpindleLoad: true })
{
try
{
var client2 = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
var loads = await client2.GetSpindleLoadsAsync(ct).ConfigureAwait(false);
for (var i = 0; i < loads.Count; i++) state.LastSpindleLoads[i] = loads[i];
}
catch { /* first-tick poll will retry */ }
}
var programPollDue = DateTime.MinValue;
var timerPollDue = DateTime.MinValue;
while (!ct.IsCancellationRequested)
{
try
{
var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
var cache = state.FixedTreeCache;
if (cache is null) break;
FocasDynamicSnapshot? firstAxisSnap = null;
for (var i = 0; i < cache.Axes.Count; i++)
{
var axisIndex = i + 1; // FOCAS uses 1-based axis indexing
var axis = cache.Axes[i];
var snap = await client.ReadDynamicAsync(axisIndex, ct).ConfigureAwait(false);
PublishAxisSnapshot(state, axis, snap);
if (i == 0) { firstAxisSnap = snap; PublishRateSnapshot(state, snap); }
}
// Servo loads + spindle loads — both return bulk arrays, so folding
// into the axis cadence is cheap. Each is gated by the bootstrap
// capability probe — unsupported on this series = silent skip.
if (cache.Capabilities.ServoLoad)
{
try
{
var loads = await client.GetServoLoadsAsync(ct).ConfigureAwait(false);
PublishServoLoads(state, loads);
}
catch { /* transient — next tick retries */ }
}
if (cache.Capabilities.SpindleLoad)
{
try
{
var loads = await client.GetSpindleLoadsAsync(ct).ConfigureAwait(false);
for (var i = 0; i < loads.Count; i++) state.LastSpindleLoads[i] = loads[i];
}
catch { /* transient */ }
}
// Program-info poll runs on its own cadence — much slower than the axis
// poll because program / mode transitions are operator-driven.
var programInterval = _options.FixedTree.ProgramPollInterval;
if (cache.Capabilities.ProgramInfo
&& programInterval > TimeSpan.Zero && DateTime.UtcNow >= programPollDue)
{
try
{
var program = await client.GetProgramInfoAsync(ct).ConfigureAwait(false);
state.LastProgramInfo = program;
if (firstAxisSnap is { } s) state.LastProgramAxisRef = s;
}
catch { /* transient — next tick retries */ }
programPollDue = DateTime.UtcNow + programInterval;
}
// Timers — slowest cadence. Fires 4 FWLIB calls per tick (one per kind).
var timerInterval = _options.FixedTree.TimerPollInterval;
if (cache.Capabilities.Timers
&& timerInterval > TimeSpan.Zero && DateTime.UtcNow >= timerPollDue)
{
foreach (FocasTimerKind kind in Enum.GetValues<FocasTimerKind>())
{
try
{
var t = await client.GetTimerAsync(kind, ct).ConfigureAwait(false);
state.LastTimers[kind] = t;
}
catch { /* per-kind failures are non-fatal */ }
}
timerPollDue = DateTime.UtcNow + timerInterval;
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
catch { /* next tick retries — transient blips are expected */ }
try { await Task.Delay(_options.FixedTree.PollInterval, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { break; }
}
}
/// <summary>
/// Cache a fresh axis snapshot. The poll loop doesn't fire <c>OnDataChange</c>
/// directly — subscribers go through the normal <c>SubscribeAsync</c> →
/// <see cref="PollGroupEngine"/> → <see cref="ReadAsync"/> path, which hits
/// <see cref="TryReadFixedTree"/> and returns these cached values.
/// </summary>
private static void PublishAxisSnapshot(DeviceState state, FocasAxisName axis, FocasDynamicSnapshot snap)
{
var host = state.Options.HostAddress;
state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axis.Display}/AbsolutePosition")] = snap.AbsolutePosition;
state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axis.Display}/MachinePosition")] = snap.MachinePosition;
state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axis.Display}/RelativePosition")] = snap.RelativePosition;
state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axis.Display}/DistanceToGo")] = snap.DistanceToGo;
}
private static void PublishRateSnapshot(DeviceState state, FocasDynamicSnapshot snap)
{
var host = state.Options.HostAddress;
state.LastFixedSnapshots[FixedTreeReference(host, "Axes/FeedRate/Actual")] = snap.ActualFeedRate;
state.LastFixedSnapshots[FixedTreeReference(host, "Axes/SpindleSpeed/Actual")] = snap.ActualSpindleSpeed;
}
/// <summary>
/// Cache servo-load percentages keyed by axis name. Stored separately from
/// <c>LastFixedSnapshots</c> (which is int-typed) so the double-valued load
/// values don't need casting on every read.
/// </summary>
private static void PublishServoLoads(DeviceState state, IReadOnlyList<FocasServoLoad> loads)
{
foreach (var load in loads)
state.LastServoLoads[load.AxisName] = load.LoadPercent;
}
private static object? TimerValue(DeviceState state, FocasTimerKind kind) =>
state.LastTimers.TryGetValue(kind, out var t) ? (object)t.TotalSeconds : null;
/// <summary>
/// Call an optional probe that returns a collection; swallow any exception
/// and return <paramref name="fallback"/>. Used by bootstrap to capture
/// per-series capability without letting one failed probe take down the
/// entire bootstrap sequence.
/// </summary>
private static async Task<IReadOnlyList<T>> SafeProbe<T>(
Func<Task<IReadOnlyList<T>>> probe, IReadOnlyList<T> fallback)
{
try { return await probe().ConfigureAwait(false); }
catch { return fallback; }
}
/// <summary>
/// Nullable variant — probe returns a single object or null on failure.
/// </summary>
private static async Task<T?> SafeTryProbe<T>(Func<Task<T>> probe) where T : class
{
try { return await probe().ConfigureAwait(false); }
catch { return null; }
}
/// <summary>
/// Read cached last-fixed-tree snapshots. Returns the projected value when
/// the reference looks like a fixed-tree FullName; null when it doesn't
/// (callers fall through to the user-authored tag path).
/// </summary>
private DataValueSnapshot? TryReadFixedTree(string reference, DateTime now)
{
foreach (var state in _devices.Values)
{
if (!reference.StartsWith(state.Options.HostAddress + "/", StringComparison.OrdinalIgnoreCase)) continue;
if (state.LastFixedSnapshots.TryGetValue(reference, out var raw))
return new DataValueSnapshot((double)raw, FocasStatusMapper.Good, now, now);
// Servo-load match: reference shape is "{host}/Axes/{name}/ServoLoad"
var suffixFull = reference[(state.Options.HostAddress.Length + 1)..];
if (suffixFull.StartsWith("Axes/", StringComparison.Ordinal) && suffixFull.EndsWith("/ServoLoad", StringComparison.Ordinal))
{
var axisName = suffixFull["Axes/".Length..^"/ServoLoad".Length];
if (state.LastServoLoads.TryGetValue(axisName, out var load))
return new DataValueSnapshot(load, FocasStatusMapper.Good, now, now);
}
// Spindle matches: "{host}/Spindle/{name}/Load" + "{host}/Spindle/{name}/MaxRpm"
if (suffixFull.StartsWith("Spindle/", StringComparison.Ordinal)
&& state.FixedTreeCache is { } spindleCache)
{
var tail = suffixFull["Spindle/".Length..];
var slash = tail.IndexOf('/');
if (slash > 0)
{
var spindleName = tail[..slash];
var field = tail[(slash + 1)..];
var idx = -1;
for (var i = 0; i < spindleCache.Spindles.Count; i++)
{
var s = spindleCache.Spindles[i];
var display = string.IsNullOrEmpty(s.Display) ? $"S{i + 1}" : s.Display;
if (string.Equals(display, spindleName, StringComparison.OrdinalIgnoreCase)) { idx = i; break; }
}
if (idx >= 0)
{
object? value = field switch
{
"Load" => state.LastSpindleLoads.TryGetValue(idx, out var l) ? (object)l : null,
"MaxRpm" => idx < spindleCache.SpindleMaxRpms.Count ? (object)spindleCache.SpindleMaxRpms[idx] : null,
_ => null,
};
if (value is not null)
return new DataValueSnapshot(value, FocasStatusMapper.Good, now, now);
}
}
}
// Identity strings + program / op-mode fields aren't cached as doubles —
// re-derive from the struct caches.
if (state.FixedTreeCache is { } cache)
{
var suffix = reference[(state.Options.HostAddress.Length + 1)..];
var value = suffix switch
{
"Identity/SeriesNumber" => (object)cache.SysInfo.Series,
"Identity/Version" => cache.SysInfo.Version,
"Identity/MaxAxes" => cache.SysInfo.MaxAxis,
"Identity/CncType" => cache.SysInfo.CncType,
"Identity/MtType" => cache.SysInfo.MtType,
"Identity/AxisCount" => cache.SysInfo.AxesCount,
"Program/Name" => (object?)state.LastProgramInfo?.Name,
"Program/ONumber" => state.LastProgramInfo?.ONumber,
"Program/BlockCount" => state.LastProgramInfo?.BlockCount,
"Program/Number" => state.LastProgramAxisRef?.ProgramNumber,
"Program/MainNumber" => state.LastProgramAxisRef?.MainProgramNumber,
"Program/Sequence" => state.LastProgramAxisRef?.SequenceNumber,
"OperationMode/Mode" => state.LastProgramInfo?.Mode,
"OperationMode/ModeText" => state.LastProgramInfo is { } pi
? FocasOpMode.ToText(pi.Mode) : null,
"Timers/PowerOnSeconds" => TimerValue(state, FocasTimerKind.PowerOn),
"Timers/OperatingSeconds" => TimerValue(state, FocasTimerKind.Operating),
"Timers/CuttingSeconds" => TimerValue(state, FocasTimerKind.Cutting),
"Timers/CycleSeconds" => TimerValue(state, FocasTimerKind.Cycle),
_ => null,
};
if (value is not null)
return new DataValueSnapshot(value, FocasStatusMapper.Good, now, now);
}
}
return null;
}
private async Task RecycleLoopAsync(DeviceState state, CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try { await Task.Delay(_options.HandleRecycle.Interval, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { break; }
// Close the current handle — the next Read / Write / Probe call triggers
// EnsureConnectedAsync, which reopens a fresh one. We don't block here on
// reconnect because the goal is just to release the FWLIB handle slot; a
// readable tick one probe cycle later is an acceptable cost.
try { state.DisposeClient(); }
catch { /* already disposed or race — next EnsureConnected recovers */ }
}
}
private void TransitionDeviceState(DeviceState state, HostState newState)
{
HostState old;
lock (state.ProbeLock)
{
old = state.HostState;
if (old == newState) return;
state.HostState = newState;
state.HostStateChangedUtc = DateTime.UtcNow;
}
OnHostStatusChanged?.Invoke(this,
new HostStatusChangedEventArgs(state.Options.HostAddress, old, newState));
}
// ---- IAlarmSource ----
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
{
if (_alarmProjection is null)
throw new NotSupportedException(
"FOCAS alarm projection is disabled — set FocasDriverOptions.AlarmProjection.Enabled=true to opt in.");
return _alarmProjection.SubscribeAsync(sourceNodeIds, cancellationToken);
}
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) =>
_alarmProjection is { } p ? p.UnsubscribeAsync(handle, cancellationToken) : Task.CompletedTask;
public Task AcknowledgeAsync(
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken) =>
_alarmProjection is { } p ? p.AcknowledgeAsync(acknowledgements, cancellationToken) : Task.CompletedTask;
internal void InvokeAlarmEvent(AlarmEventArgs args) => OnAlarmEvent?.Invoke(this, args);
/// <summary>
/// Poll every configured device's active-alarm list in one pass. Used by the alarm
/// projection — kept <c>internal</c> rather than <c>public</c> because callers that
/// want alarm events should subscribe through <c>IAlarmSource</c> instead.
/// </summary>
internal async Task<IReadOnlyList<(string HostAddress, IReadOnlyList<FocasActiveAlarm> Alarms)>>
ReadActiveAlarmsAcrossDevicesAsync(HashSet<string>? deviceFilter, CancellationToken ct)
{
var result = new List<(string, IReadOnlyList<FocasActiveAlarm>)>();
foreach (var state in _devices.Values)
{
if (deviceFilter is not null && !deviceFilter.Contains(state.Options.HostAddress)) continue;
try
{
var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
var alarms = await client.ReadAlarmsAsync(ct).ConfigureAwait(false);
result.Add((state.Options.HostAddress, alarms));
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
catch { /* surface a device-local fault on the next tick */ }
}
return result;
}
// ---- IPerCallHostResolver ----
public string ResolveHost(string fullReference)
{
if (_tagsByName.TryGetValue(fullReference, out var def))
return def.DeviceHostAddress;
return _options.Devices.FirstOrDefault()?.HostAddress ?? DriverInstanceId;
}
private async Task<IFocasClient> EnsureConnectedAsync(DeviceState device, CancellationToken ct)
{
if (device.Client is { IsConnected: true } c) return c;
device.Client ??= _clientFactory.Create();
try
{
await device.Client.ConnectAsync(device.ParsedAddress, _options.Timeout, ct).ConfigureAwait(false);
}
catch
{
device.Client.Dispose();
device.Client = null;
throw;
}
return device.Client;
}
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
/// <summary>
/// Per-device fixed-tree cache populated once at first successful connect and
/// read-only thereafter. Used by <see cref="DiscoverAsync"/> to render the
/// tree + by <see cref="TryReadFixedTree"/> for synchronous Identity/* reads.
/// </summary>
internal sealed record FocasFixedTreeCache(
FocasSysInfo SysInfo,
IReadOnlyList<FocasAxisName> Axes,
IReadOnlyList<FocasSpindleName> Spindles,
IReadOnlyList<int> SpindleMaxRpms,
FocasFixedTreeCapabilities Capabilities);
/// <summary>
/// Per-device optional-API capability flags — which of the "this may or may not
/// exist on this CNC series" calls succeeded at bootstrap. Drives per-series
/// node suppression so a 16i that doesn't export <c>cnc_rdspmaxrpm</c> simply
/// doesn't get a <c>Spindle/{name}/MaxRpm</c> node (instead of surfacing
/// <c>BadDeviceFailure</c> on every read).
/// </summary>
internal sealed record FocasFixedTreeCapabilities(
bool Spindles, // cnc_rdspdlname returned 1+ spindle names
bool SpindleLoad, // cnc_rdspload bootstrap probe succeeded
bool SpindleMaxRpm, // cnc_rdspmaxrpm bootstrap probe succeeded
bool ServoLoad, // cnc_rdsvmeter bootstrap probe returned data
bool ProgramInfo, // cnc_exeprgname2 + cnc_rdblkcount + cnc_rdopmode work
bool Timers); // cnc_rdtimer works for at least PowerOn
internal sealed class DeviceState(FocasHostAddress parsedAddress, FocasDeviceOptions options)
{
public FocasHostAddress ParsedAddress { get; } = parsedAddress;
public FocasDeviceOptions Options { get; } = options;
public IFocasClient? Client { get; set; }
public object ProbeLock { get; } = new();
public HostState HostState { get; set; } = HostState.Unknown;
public DateTime HostStateChangedUtc { get; set; } = DateTime.UtcNow;
public CancellationTokenSource? ProbeCts { get; set; }
public CancellationTokenSource? RecycleCts { get; set; }
public CancellationTokenSource? FixedTreeCts { get; set; }
public FocasFixedTreeCache? FixedTreeCache { get; set; }
public Dictionary<string, int> LastFixedSnapshots { get; } = new(StringComparer.OrdinalIgnoreCase);
public FocasProgramInfo? LastProgramInfo { get; set; }
/// <summary>Cached first-axis dynamic snapshot — feeds Program/Number, /MainNumber, /Sequence.</summary>
public FocasDynamicSnapshot? LastProgramAxisRef { get; set; }
public Dictionary<FocasTimerKind, FocasTimer> LastTimers { get; } = [];
public Dictionary<string, double> LastServoLoads { get; } = new(StringComparer.OrdinalIgnoreCase);
public Dictionary<int, int> LastSpindleLoads { get; } = [];
public void DisposeClient()
{
Client?.Dispose();
Client = null;
}
}
}

View File

@@ -0,0 +1,165 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// <summary>
/// Static factory registration helper for <see cref="FocasDriver"/>. Server's
/// Program.cs calls <see cref="Register"/> once at startup; the bootstrapper
/// then materialises FOCAS DriverInstance rows from the central config DB
/// into live driver instances.
/// </summary>
/// <remarks>
/// The DriverConfig JSON selects the <see cref="IFocasClientFactory"/> backend:
/// <list type="bullet">
/// <item><c>"Backend": "wire"</c> (default) — pure-managed FOCAS2 wire
/// client (<see cref="WireFocasClientFactory"/>) speaking directly to
/// the CNC on TCP:8193.</item>
/// <item><c>"Backend": "unimplemented"</c> / <c>"none"</c> / <c>"stub"</c>
/// — returns the no-op factory; useful for scaffolding DriverInstance
/// rows before the CNC endpoint is reachable.</item>
/// </list>
/// Devices / Tags / Probe / Timeout / Series come from the same JSON and
/// feed directly into <see cref="FocasDriverOptions"/>.
/// </remarks>
public static class FocasDriverFactoryExtensions
{
public const string DriverTypeName = "FOCAS";
/// <summary>
/// Register the FOCAS driver factory in the supplied <see cref="DriverFactoryRegistry"/>.
/// Throws if 'FOCAS' is already registered — single-instance per process.
/// </summary>
public static void Register(DriverFactoryRegistry registry)
{
ArgumentNullException.ThrowIfNull(registry);
registry.Register(DriverTypeName, CreateInstance);
}
internal static FocasDriver CreateInstance(string driverInstanceId, string driverConfigJson)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
var dto = JsonSerializer.Deserialize<FocasDriverConfigDto>(driverConfigJson, JsonOptions)
?? throw new InvalidOperationException(
$"FOCAS driver config for '{driverInstanceId}' deserialised to null");
// Eager-validate top-level Series so a typo fails fast regardless of whether Devices
// are populated yet (common during rollout when rows are seeded before CNCs arrive).
_ = ParseSeries(dto.Series);
var options = new FocasDriverOptions
{
Devices = dto.Devices is { Count: > 0 }
? [.. dto.Devices.Select(d => new FocasDeviceOptions(
HostAddress: d.HostAddress ?? throw new InvalidOperationException(
$"FOCAS config for '{driverInstanceId}' has a device missing HostAddress"),
DeviceName: d.DeviceName,
Series: ParseSeries(d.Series ?? dto.Series)))]
: [],
Tags = dto.Tags is { Count: > 0 }
? [.. dto.Tags.Select(t => new FocasTagDefinition(
Name: t.Name ?? throw new InvalidOperationException(
$"FOCAS config for '{driverInstanceId}' has a tag missing Name"),
DeviceHostAddress: t.DeviceHostAddress ?? throw new InvalidOperationException(
$"FOCAS tag '{t.Name}' in '{driverInstanceId}' missing DeviceHostAddress"),
Address: t.Address ?? throw new InvalidOperationException(
$"FOCAS tag '{t.Name}' in '{driverInstanceId}' missing Address"),
DataType: ParseDataType(t.DataType, t.Name!, driverInstanceId),
Writable: t.Writable ?? true,
WriteIdempotent: t.WriteIdempotent ?? false))]
: [],
Probe = new FocasProbeOptions
{
Enabled = dto.Probe?.Enabled ?? true,
Interval = TimeSpan.FromMilliseconds(dto.Probe?.IntervalMs ?? 5_000),
Timeout = TimeSpan.FromMilliseconds(dto.Probe?.TimeoutMs ?? 2_000),
},
Timeout = TimeSpan.FromMilliseconds(dto.TimeoutMs ?? 2_000),
};
var clientFactory = BuildClientFactory(dto, driverInstanceId);
return new FocasDriver(options, driverInstanceId, clientFactory);
}
internal static IFocasClientFactory BuildClientFactory(
FocasDriverConfigDto dto, string driverInstanceId)
{
var backend = (dto.Backend ?? "wire").Trim().ToLowerInvariant();
return backend switch
{
"wire" => new WireFocasClientFactory(),
"unimplemented" or "none" or "stub" => new UnimplementedFocasClientFactory(),
_ => throw new InvalidOperationException(
$"FOCAS driver config for '{driverInstanceId}' has unknown Backend '{dto.Backend}'. " +
"Expected one of: wire, unimplemented. " +
"(The legacy 'ipc' / 'fwlib' backends were retired in the Wire migration — " +
"see docs/drivers/FOCAS.md.)"),
};
}
private static FocasCncSeries ParseSeries(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return FocasCncSeries.Unknown;
return Enum.TryParse<FocasCncSeries>(raw, ignoreCase: true, out var s)
? s
: throw new InvalidOperationException(
$"FOCAS Series '{raw}' is not one of {string.Join(", ", Enum.GetNames<FocasCncSeries>())}");
}
private static FocasDataType ParseDataType(string? raw, string tagName, string driverInstanceId)
{
if (string.IsNullOrWhiteSpace(raw))
throw new InvalidOperationException(
$"FOCAS tag '{tagName}' in '{driverInstanceId}' missing DataType");
return Enum.TryParse<FocasDataType>(raw, ignoreCase: true, out var dt)
? dt
: throw new InvalidOperationException(
$"FOCAS tag '{tagName}' has unknown DataType '{raw}'. " +
$"Expected one of {string.Join(", ", Enum.GetNames<FocasDataType>())}");
}
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
internal sealed class FocasDriverConfigDto
{
public string? Backend { get; init; }
public string? Series { get; init; }
public int? TimeoutMs { get; init; }
public List<FocasDeviceDto>? Devices { get; init; }
public List<FocasTagDto>? Tags { get; init; }
public FocasProbeDto? Probe { get; init; }
}
internal sealed class FocasDeviceDto
{
public string? HostAddress { get; init; }
public string? DeviceName { get; init; }
public string? Series { get; init; }
}
internal sealed class FocasTagDto
{
public string? Name { get; init; }
public string? DeviceHostAddress { get; init; }
public string? Address { get; init; }
public string? DataType { get; init; }
public bool? Writable { get; init; }
public bool? WriteIdempotent { get; init; }
}
internal sealed class FocasProbeDto
{
public bool? Enabled { get; init; }
public int? IntervalMs { get; init; }
public int? TimeoutMs { get; init; }
}
}

View File

@@ -0,0 +1,115 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// <summary>
/// FOCAS driver configuration. One instance supports N CNC devices. Per plan decision #144
/// each device gets its own <c>(DriverInstanceId, HostAddress)</c> bulkhead key at the
/// Phase 6.1 resilience layer.
/// </summary>
public sealed class FocasDriverOptions
{
public IReadOnlyList<FocasDeviceOptions> Devices { get; init; } = [];
public IReadOnlyList<FocasTagDefinition> Tags { get; init; } = [];
public FocasProbeOptions Probe { get; init; } = new();
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
public FocasAlarmProjectionOptions AlarmProjection { get; init; } = new();
public FocasHandleRecycleOptions HandleRecycle { get; init; } = new();
public FocasFixedTreeOptions FixedTree { get; init; } = new();
}
/// <summary>
/// Fixed-node tree exposed by FOCAS per <c>docs/v2/driver-specs.md §7</c> —
/// <c>Identity/</c>, <c>Axes/{name}/</c>, etc. populated from
/// <c>cnc_sysinfo</c> / <c>cnc_rdaxisname</c> / <c>cnc_rddynamic2</c>. Disabled by
/// default so existing configs that only use user-authored tags don't grow new
/// nodes on upgrade.
/// </summary>
public sealed class FocasFixedTreeOptions
{
/// <summary>Enable the fixed-node tree for every configured device.</summary>
public bool Enabled { get; init; } = false;
/// <summary>
/// Poll cadence for <c>cnc_rddynamic2</c>. Each tick calls the API once per
/// configured axis + publishes OnDataChange for the axis subtree. Real CNCs
/// serve ~100ms loops comfortably; the default is conservative.
/// </summary>
public TimeSpan PollInterval { get; init; } = TimeSpan.FromMilliseconds(250);
/// <summary>
/// Poll cadence for program + operation-mode info. Slower than the axis
/// poll because program / mode transitions happen on operator timescales.
/// Zero / negative disables the program poll entirely.
/// </summary>
public TimeSpan ProgramPollInterval { get; init; } = TimeSpan.FromSeconds(1);
/// <summary>
/// Poll cadence for timers (power-on / operating / cutting / cycle).
/// These change at human timescales — default is 30s. Zero / negative
/// disables the timer poll entirely.
/// </summary>
public TimeSpan TimerPollInterval { get; init; } = TimeSpan.FromSeconds(30);
}
/// <summary>
/// Proactive session-recycle cadence. Fanuc CNCs have a finite FWLIB handle pool
/// (~510 concurrent connections) and certain series have documented handle-leak bugs
/// that manifest after long uptime. When <see cref="Enabled"/> is <c>true</c> the
/// driver closes + reopens each device's session on the <see cref="Interval"/> cadence,
/// forcing FWLIB to release its handle slot back to the pool. Reads / writes during
/// recycle wait for the reconnect rather than failing — worst case an operator sees a
/// brief read latency spike once per cadence.
/// </summary>
/// <remarks>
/// Disabled by default because a healthy CNC + driver doesn't need it. Enable when
/// field experience shows handle exhaustion against a specific series / firmware.
/// Typical tuning: 30 min for sites running multiple OtOpcUa instances against the
/// same CNC (they share the pool); 6 h for a single-client deployment.
/// </remarks>
public sealed class FocasHandleRecycleOptions
{
public bool Enabled { get; init; } = false;
public TimeSpan Interval { get; init; } = TimeSpan.FromHours(1);
}
/// <summary>
/// Controls the CNC active-alarm polling projection that surfaces FOCAS alarms via
/// <c>IAlarmSource</c>. Disabled by default — operators opt in by setting
/// <see cref="Enabled"/> in <c>appsettings.json</c>.
/// </summary>
public sealed class FocasAlarmProjectionOptions
{
public bool Enabled { get; init; } = false;
/// <summary>Poll cadence. One <c>cnc_rdalmmsg2</c> call per device per tick.</summary>
public TimeSpan PollInterval { get; init; } = TimeSpan.FromSeconds(2);
}
/// <summary>
/// One CNC the driver talks to. <paramref name="Series"/> enables per-series
/// address validation at <see cref="FocasDriver.InitializeAsync"/>; leave as
/// <see cref="FocasCncSeries.Unknown"/> to skip validation (legacy behaviour).
/// </summary>
public sealed record FocasDeviceOptions(
string HostAddress,
string? DeviceName = null,
FocasCncSeries Series = FocasCncSeries.Unknown);
/// <summary>
/// One FOCAS-backed OPC UA variable. <paramref name="Address"/> is the canonical FOCAS
/// address string that parses via <see cref="FocasAddress.TryParse"/> —
/// <c>X0.0</c> / <c>R100</c> / <c>PARAM:1815/0</c> / <c>MACRO:500</c>.
/// </summary>
public sealed record FocasTagDefinition(
string Name,
string DeviceHostAddress,
string Address,
FocasDataType DataType,
bool Writable = true,
bool WriteIdempotent = false);
public sealed class FocasProbeOptions
{
public bool Enabled { get; init; } = true;
public TimeSpan Interval { get; init; } = TimeSpan.FromSeconds(5);
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
}

View File

@@ -0,0 +1,41 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// <summary>
/// Parsed FOCAS target address — IP + TCP port. Canonical <c>focas://{ip}[:{port}]</c>.
/// Default port 8193 (Fanuc-reserved FOCAS Ethernet port).
/// </summary>
public sealed record FocasHostAddress(string Host, int Port)
{
/// <summary>Fanuc-reserved TCP port for FOCAS Ethernet.</summary>
public const int DefaultPort = 8193;
public override string ToString() => Port == DefaultPort
? $"focas://{Host}"
: $"focas://{Host}:{Port}";
public static FocasHostAddress? TryParse(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
const string prefix = "focas://";
if (!value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return null;
var body = value[prefix.Length..];
if (string.IsNullOrEmpty(body)) return null;
var colonIdx = body.LastIndexOf(':');
string host;
var port = DefaultPort;
if (colonIdx >= 0)
{
host = body[..colonIdx];
if (!int.TryParse(body[(colonIdx + 1)..], out port) || port is <= 0 or > 65535)
return null;
}
else
{
host = body;
}
if (string.IsNullOrEmpty(host)) return null;
return new FocasHostAddress(host, port);
}
}

View File

@@ -0,0 +1,48 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// <summary>
/// Maps FOCAS / FWLIB return codes to OPC UA StatusCodes. The FWLIB C API uses an
/// <c>EW_*</c> constant family per the Fanuc FOCAS/1 and FOCAS/2 documentation
/// (<c>EW_OK = 0</c>, <c>EW_NUMBER</c>, <c>EW_SOCKET</c>, etc.). Mirrors the shape of the
/// AbCip / TwinCAT mappers so Admin UI status displays stay uniform across drivers.
/// </summary>
public static class FocasStatusMapper
{
public const uint Good = 0u;
public const uint BadInternalError = 0x80020000u;
public const uint BadNodeIdUnknown = 0x80340000u;
public const uint BadNotWritable = 0x803B0000u;
public const uint BadOutOfRange = 0x803C0000u;
public const uint BadNotSupported = 0x803D0000u;
public const uint BadDeviceFailure = 0x80550000u;
public const uint BadCommunicationError = 0x80050000u;
public const uint BadTimeout = 0x800A0000u;
public const uint BadTypeMismatch = 0x80730000u;
/// <summary>
/// Map common FWLIB <c>EW_*</c> return codes. The values below match Fanuc's published
/// numeric conventions (EW_OK=0, EW_FUNC=1, EW_NUMBER=3, EW_LENGTH=4, EW_ATTRIB=7,
/// EW_DATA=8, EW_NOOPT=6, EW_PROT=5, EW_OVRFLOW=2, EW_PARITY=9, EW_PASSWD=11,
/// EW_BUSY=-1, EW_HANDLE=-8, EW_VERSION=-9, EW_UNEXP=-10, EW_SOCKET=-16).
/// </summary>
public static uint MapFocasReturn(int ret) => ret switch
{
0 => Good,
1 => BadNotSupported, // EW_FUNC — CNC does not support this function
2 => BadOutOfRange, // EW_OVRFLOW
3 => BadOutOfRange, // EW_NUMBER
4 => BadOutOfRange, // EW_LENGTH
5 => BadNotWritable, // EW_PROT
6 => BadNotSupported, // EW_NOOPT — optional CNC feature missing
7 => BadTypeMismatch, // EW_ATTRIB
8 => BadNodeIdUnknown, // EW_DATA — invalid data address
9 => BadCommunicationError, // EW_PARITY
11 => BadNotWritable, // EW_PASSWD
-1 => BadDeviceFailure, // EW_BUSY
-8 => BadInternalError, // EW_HANDLE — CNC handle not available
-9 => BadNotSupported, // EW_VERSION — FWLIB vs CNC version mismatch
-10 => BadCommunicationError, // EW_UNEXP
-16 => BadCommunicationError, // EW_SOCKET
_ => BadCommunicationError,
};
}

View File

@@ -0,0 +1,287 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// <summary>
/// Wire-layer abstraction over one FOCAS session to a CNC. The driver holds one per
/// configured device; lifetime matches the device.
/// </summary>
/// <remarks>
/// <para>The default implementation is <see cref="Wire.WireFocasClient"/> — a pure-managed
/// FOCAS/2 Ethernet client that speaks the wire protocol directly on TCP:8193. No
/// P/Invoke, no native DLLs, no out-of-process isolation.</para>
///
/// <para><see cref="UnimplementedFocasClientFactory"/> is a scaffolding backend that
/// throws on <see cref="IFocasClientFactory.Create"/> — selected by
/// <c>"Backend": "unimplemented"</c> so a DriverInstance row can be seeded before the CNC
/// endpoint is reachable without silently reading stale data.</para>
/// </remarks>
public interface IFocasClient : IDisposable
{
/// <summary>Open the FWLIB handle + TCP session. Idempotent.</summary>
Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken cancellationToken);
/// <summary>True when the FWLIB handle is valid + the socket is up.</summary>
bool IsConnected { get; }
/// <summary>
/// Read the value at <paramref name="address"/> in the requested
/// <paramref name="type"/>. Returns a boxed .NET value + the OPC UA status mapped
/// through <see cref="FocasStatusMapper"/>.
/// </summary>
Task<(object? value, uint status)> ReadAsync(
FocasAddress address,
FocasDataType type,
CancellationToken cancellationToken);
/// <summary>
/// Write <paramref name="value"/> to <paramref name="address"/>. Returns the mapped
/// OPC UA status (0 = Good).
/// </summary>
Task<uint> WriteAsync(
FocasAddress address,
FocasDataType type,
object? value,
CancellationToken cancellationToken);
/// <summary>
/// Cheap health probe — e.g. <c>cnc_rdcncstat</c>. Returns <c>true</c> when the CNC
/// responds with any valid status.
/// </summary>
Task<bool> ProbeAsync(CancellationToken cancellationToken);
/// <summary>
/// Read active alarm messages from the CNC via <c>cnc_rdalmmsg2</c>. Returns
/// zero-or-more active alarms. Null / empty list means "no alarms currently
/// active". IAlarmSource projection polls this at a configurable interval +
/// emits transitions (raise / clear) on the driver's <c>OnAlarmEvent</c>.
/// </summary>
Task<IReadOnlyList<FocasActiveAlarm>> ReadAlarmsAsync(CancellationToken cancellationToken);
// ---- Fixed-tree T1 (identity + axis discovery + fast-poll dynamic bundle) ----
/// <summary>
/// Read CNC identity via <c>cnc_sysinfo</c>. Populates the <c>Identity/*</c>
/// subtree of the fixed-node surface. Callable once at session open; the
/// values don't change across the session.
/// </summary>
Task<FocasSysInfo> GetSysInfoAsync(CancellationToken cancellationToken);
/// <summary>
/// Read the CNC's configured axis names via <c>cnc_rdaxisname</c>. The driver
/// uses these to build the <c>Axes/{name}/</c> subtree and to index
/// <see cref="ReadDynamicAsync"/> calls.
/// </summary>
Task<IReadOnlyList<FocasAxisName>> GetAxisNamesAsync(CancellationToken cancellationToken);
/// <summary>
/// Read the CNC's configured spindle names via <c>cnc_rdspdlname</c>. Drives
/// the <c>Spindle/{name}/</c> subtree.
/// </summary>
Task<IReadOnlyList<FocasSpindleName>> GetSpindleNamesAsync(CancellationToken cancellationToken);
/// <summary>
/// Read the fast-poll dynamic bundle for one axis via <c>cnc_rddynamic2</c>.
/// Returns the current position quadruple (absolute / machine / relative /
/// distance-to-go) plus actual feed rate + actual spindle speed + alarm
/// flags + program / sequence numbers — one network round-trip per call.
/// </summary>
Task<FocasDynamicSnapshot> ReadDynamicAsync(int axisIndex, CancellationToken cancellationToken);
// ---- Fixed-tree T2 (program + operation mode) ----
/// <summary>
/// Aggregate program + operation-mode snapshot. One wire round-trip per
/// underlying FWLIB call — <c>cnc_rdblkcount</c>, <c>cnc_exeprgname2</c>,
/// <c>cnc_rdopmode</c>. The driver polls this on a slower cadence than
/// <see cref="ReadDynamicAsync"/> since program / mode transitions happen
/// on human-operator timescales.
/// </summary>
Task<FocasProgramInfo> GetProgramInfoAsync(CancellationToken cancellationToken);
// ---- Fixed-tree T3 (timers) ----
/// <summary>
/// Read one CNC cumulative timer. Kind selects PowerOn / Operating / Cutting /
/// Cycle. Values are seconds — the managed side already converted the native
/// minute+msec representation so downstream nodes display uniform units.
/// </summary>
Task<FocasTimer> GetTimerAsync(FocasTimerKind kind, CancellationToken cancellationToken);
// ---- Fixed-tree T3.5 (servo meters) ----
/// <summary>
/// Read the servo-load meter percentages across all configured axes.
/// Values are percentages (scaled by <c>10^Dec</c>). Empty list on a
/// disconnected session or unsupported CNC.
/// </summary>
Task<IReadOnlyList<FocasServoLoad>> GetServoLoadsAsync(CancellationToken cancellationToken);
// ---- Fixed-tree T3.6 (spindle meters) ----
/// <summary>
/// Read per-spindle load percentages. Result list index corresponds to
/// spindle index from <see cref="GetSpindleNamesAsync"/>. Empty list on a
/// disconnected session or when the CNC doesn't support the call (older
/// series like 16i may return EW_FUNC).
/// </summary>
Task<IReadOnlyList<int>> GetSpindleLoadsAsync(CancellationToken cancellationToken);
/// <summary>
/// Read per-spindle maximum RPM values. Static configuration, fetched once at
/// bootstrap. Index alignment as per <see cref="GetSpindleLoadsAsync"/>.
/// </summary>
Task<IReadOnlyList<int>> GetSpindleMaxRpmsAsync(CancellationToken cancellationToken);
}
/// <summary>One servo-meter entry — one axis's current load percentage.</summary>
public sealed record FocasServoLoad(string AxisName, double LoadPercent);
/// <summary>Which cumulative counter <see cref="IFocasClient.GetTimerAsync"/> reads.</summary>
public enum FocasTimerKind
{
/// <summary>Machine power-on hours — resets never.</summary>
PowerOn = 0,
/// <summary>Cycle operating time — resets when the operator clears the counter.</summary>
Operating = 1,
/// <summary>Cutting time — only counts while in cutting feed.</summary>
Cutting = 2,
/// <summary>Cycle time since the last program start.</summary>
Cycle = 3,
}
/// <summary>One cumulative timer reading. <see cref="TotalSeconds"/> is the canonical unit.</summary>
public sealed record FocasTimer(FocasTimerKind Kind, int Minutes, int Milliseconds)
{
/// <summary>Cumulative time in seconds — <c>Minutes * 60 + Milliseconds / 1000</c>.</summary>
public double TotalSeconds => Minutes * 60.0 + Milliseconds / 1000.0;
}
/// <summary>
/// CNC identity snapshot from <c>cnc_sysinfo</c>. Strings are trimmed ASCII.
/// </summary>
public sealed record FocasSysInfo(
int AddInfo,
int MaxAxis,
string CncType, // "M" (mill) / "T" (lathe)
string MtType,
string Series, // e.g. "30i"
string Version, // e.g. "A1.0"
int AxesCount);
/// <summary>One configured axis name (e.g. "X", "X1").</summary>
public sealed record FocasAxisName(string Name, string Suffix)
{
/// <summary>
/// Display name — name + suffix concatenated, trimmed. Empty suffix yields
/// just the name (the common case on single-channel CNCs).
/// </summary>
public string Display => string.IsNullOrEmpty(Suffix) ? Name : $"{Name}{Suffix}";
}
/// <summary>One configured spindle name (e.g. "S1").</summary>
public sealed record FocasSpindleName(string Name, string Suffix1, string Suffix2, string Suffix3)
{
public string Display
{
get
{
var s = Name + Suffix1 + Suffix2 + Suffix3;
return s.TrimEnd('\0', ' ');
}
}
}
/// <summary>
/// Fast-poll bundle for one axis. Position values are scaled integers; the caller
/// divides by <c>10^DecimalPlaces</c> to get the decimal value. DecimalPlaces is
/// currently left to the caller to supply (via device config or a future
/// <c>cnc_getfigure</c> path once that export lands).
/// </summary>
/// <summary>
/// Program + operation-mode snapshot. Name is the currently-executing
/// program filename (e.g. "O0001.NC"); ONumber is its Fanuc O-number (1-9999).
/// Mode is the numeric code from <c>cnc_rdopmode</c> — see <see cref="FocasOpMode"/>.
/// </summary>
public sealed record FocasProgramInfo(
string Name,
int ONumber,
int BlockCount,
int Mode);
/// <summary>Human-readable text for the <see cref="FocasProgramInfo.Mode"/> integer.</summary>
public static class FocasOpMode
{
public static string ToText(int mode) => mode switch
{
0 => "MDI",
1 => "AUTO",
2 => "TJOG",
3 => "EDIT",
4 => "HANDLE",
5 => "JOG",
6 => "TEACH_IN_HANDLE",
7 => "REFERENCE",
8 => "REMOTE",
9 => "TEST",
_ => $"Mode{mode}",
};
}
public sealed record FocasDynamicSnapshot(
int AxisIndex,
int AlarmFlags,
int ProgramNumber,
int MainProgramNumber,
int SequenceNumber,
int ActualFeedRate,
int ActualSpindleSpeed,
int AbsolutePosition,
int MachinePosition,
int RelativePosition,
int DistanceToGo);
/// <summary>
/// One active alarm surfaced by <see cref="IFocasClient.ReadAlarmsAsync"/>. Shape
/// mirrors <c>ODBALMMSG2</c> but normalises the message bytes to a .NET string.
/// </summary>
public sealed record FocasActiveAlarm(
int AlarmNumber,
short Type,
short Axis,
string Message);
/// <summary>Factory for <see cref="IFocasClient"/>s. One client per configured device.</summary>
public interface IFocasClientFactory
{
IFocasClient Create();
}
/// <summary>
/// Scaffolding factory — throws on <see cref="Create"/> so a DriverInstance row can be
/// seeded ahead of the CNC endpoint being reachable without silently reading stale data.
/// Select via <c>"Backend": "unimplemented"</c> in driver config. Flip to
/// <c>"Backend": "wire"</c> once the CNC is provisioned.
/// </summary>
public sealed class UnimplementedFocasClientFactory : IFocasClientFactory
{
public IFocasClient Create() => throw new NotSupportedException(
"FOCAS driver backend is 'unimplemented'. Switch to 'Backend: \"wire\"' in driver config " +
"once the CNC is provisioned — see docs/drivers/FOCAS.md.");
}
/// <summary>
/// Well-known FOCAS alarm types from <c>fwlib32.h</c> <c>ALM_TYPE_*</c>. Narrow subset —
/// the full list is ~15 types per model; these cover the universally-present categories.
/// </summary>
public static class FocasAlarmType
{
/// <summary>Pass to <see cref="IFocasClient.ReadAlarmsAsync"/>-equivalent to mean "any type".</summary>
public const int All = -1;
public const int Parameter = 0; // ALM_P
public const int PulseCode = 1; // ALM_Y (servo)
public const int Overtravel = 2; // ALM_O
public const int Overheat = 3; // ALM_H
public const int Servo = 4; // ALM_S
public const int DataIo = 5; // ALM_T
public const int MemoryCheck = 6; // ALM_M
public const int MacroAlarm = 13; // ALM_MC — used by #3006 etc.
}

View File

@@ -0,0 +1,120 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
/// <summary>
/// PMC address-letter → FOCAS <c>ADR_*</c> numeric code. Values are the FOCAS/2 wire
/// constants passed as the <c>area</c> argument on <c>pmc_rdpmcrng</c>
/// (G=0, F=1, Y=2, X=3, A=4, R=5, T=6, K=7, C=8, D=9, E=10).
/// </summary>
public enum FocasPmcArea : short
{
G = 0,
F = 1,
Y = 2,
X = 3,
A = 4,
R = 5,
T = 6,
K = 7,
C = 8,
D = 9,
E = 10,
}
/// <summary>
/// PMC data-type numeric codes per FOCAS/2: <c>Byte=0</c>, <c>Word=1</c>, <c>Long=2</c>,
/// <c>Real=4</c>, <c>Double=5</c>. Passed as the <c>data_type</c> argument on
/// <c>pmc_rdpmcrng</c>.
/// </summary>
public enum FocasPmcDataType : short
{
Byte = 0,
Word = 1,
Long = 2,
Real = 4,
Double = 5,
}
/// <summary>
/// CNC operation mode as reported by <c>cnc_rdopmode</c>. Values are the FOCAS-defined
/// mode codes; see <see cref="FocasOperationModeExtensions.ToText"/> for the canonical
/// operator-facing labels.
/// </summary>
public enum FocasOperationMode : short
{
Mdi = 0,
Auto = 1,
TJog = 2,
Edit = 3,
Handle = 4,
Jog = 5,
TeachInHandle = 6,
Reference = 7,
Remote = 8,
Test = 9,
}
/// <summary>Extension helpers over <see cref="FocasOperationMode"/>.</summary>
public static class FocasOperationModeExtensions
{
/// <summary>
/// Canonical operator-facing label for an operation mode (e.g. <c>"AUTO"</c>,
/// <c>"EDIT"</c>). Unknown codes fall back to the raw numeric value as a string
/// so the UI still shows something interpretable.
/// </summary>
public static string ToText(this FocasOperationMode mode) => mode switch
{
FocasOperationMode.Mdi => "MDI",
FocasOperationMode.Auto => "AUTO",
FocasOperationMode.TJog => "T-JOG",
FocasOperationMode.Edit => "EDIT",
FocasOperationMode.Handle => "HANDLE",
FocasOperationMode.Jog => "JOG",
FocasOperationMode.TeachInHandle => "TEACH-IN-HANDLE",
FocasOperationMode.Reference => "REFERENCE",
FocasOperationMode.Remote => "REMOTE",
FocasOperationMode.Test => "TEST",
_ => ((short)mode).ToString(),
};
}
/// <summary>
/// Letter → <see cref="FocasPmcArea"/> lookup. Used by <see cref="WireFocasClient"/> to
/// translate a parsed <see cref="FocasAddress.PmcLetter"/> into the wire code expected by
/// <c>pmc_rdpmcrng</c>.
/// </summary>
internal static class FocasPmcAreaLookup
{
public static FocasPmcArea? FromLetter(string letter) => letter.ToUpperInvariant() switch
{
"G" => FocasPmcArea.G,
"F" => FocasPmcArea.F,
"Y" => FocasPmcArea.Y,
"X" => FocasPmcArea.X,
"A" => FocasPmcArea.A,
"R" => FocasPmcArea.R,
"T" => FocasPmcArea.T,
"K" => FocasPmcArea.K,
"C" => FocasPmcArea.C,
"D" => FocasPmcArea.D,
"E" => FocasPmcArea.E,
_ => null,
};
}
/// <summary>
/// <see cref="FocasDataType"/> → <see cref="FocasPmcDataType"/> mapping for wire PMC
/// reads. Bit reads collapse to byte — the caller extracts the bit from the returned
/// value.
/// </summary>
internal static class FocasPmcDataTypeLookup
{
public static FocasPmcDataType FromFocasDataType(FocasDataType t) => t switch
{
FocasDataType.Bit or FocasDataType.Byte => FocasPmcDataType.Byte,
FocasDataType.Int16 => FocasPmcDataType.Word,
FocasDataType.Int32 => FocasPmcDataType.Long,
FocasDataType.Float32 => FocasPmcDataType.Real,
FocasDataType.Float64 => FocasPmcDataType.Double,
_ => FocasPmcDataType.Byte,
};
}

View File

@@ -0,0 +1,883 @@
using System.Buffers.Binary;
using System.Net.Sockets;
using Microsoft.Extensions.Logging;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
/// <summary>
/// Pure-managed read-only FOCAS/2 Ethernet wire client. Speaks the proprietary Fanuc
/// binary protocol on TCP:8193 directly — no P/Invoke, no <c>Fwlib64.dll</c>, no
/// out-of-process Host. One instance owns two TCP sockets for the duration of a CNC
/// session; <see cref="ConnectAsync(string, int, int, CancellationToken)"/> runs the
/// two-socket initiate handshake and a setup request, subsequent reads reuse
/// <c>socket 2</c> serialised through an internal semaphore.
/// </summary>
/// <remarks>
/// <para><b>Read surface.</b> Covers every FOCAS call OtOpcUa's managed driver issues:
/// sysinfo, status, axis + spindle names, the <c>cnc_rddynamic2</c> fast-poll bundle,
/// parameters (typed + raw-bytes overloads), macros, PMC ranges, alarms, operation mode,
/// executing program, block count, timers, and servo / spindle meters. Writes are
/// intentionally out of scope.</para>
/// <para><b>Concurrency.</b> Callers may issue reads concurrently from multiple threads
/// — <c>socket 2</c> is guarded by a <see cref="SemaphoreSlim"/> so at most one
/// request/response pair is in flight at a time. <see cref="ConnectAsync(string, int, int, CancellationToken)"/>
/// and <see cref="DisposeAsync"/> share a second semaphore to stop the two racing.</para>
/// <para><b>Transient failures.</b> When cancellation or a socket-level error happens
/// mid-request the client closes both sockets and throws
/// <see cref="FocasWireException"/> with <see cref="FocasWireException.IsTransient"/>
/// set — the caller must reconnect before issuing the next request. The transport is
/// left deliberately torn down rather than half-open so a truncated response never
/// desynchronises the next caller's read.</para>
/// </remarks>
public sealed class FocasWireClient : IAsyncDisposable, IDisposable
{
private readonly ILogger<FocasWireClient>? _logger;
private readonly SemaphoreSlim _requestGate = new(1, 1);
private readonly SemaphoreSlim _lifetimeGate = new(1, 1);
private TcpClient? _socket1;
private TcpClient? _socket2;
private NetworkStream? _stream1;
private NetworkStream? _stream2;
private bool _connected;
private bool _disposed;
private FocasResult<WireSysInfo>? _sysInfo;
/// <summary>
/// Construct a disconnected client. Optional <paramref name="logger"/> receives
/// <c>Debug</c>-level entries per response block (command ID, RC, payload length).
/// </summary>
public FocasWireClient(ILogger<FocasWireClient>? logger = null)
{
_logger = logger;
}
/// <summary>
/// Default <c>PathId</c> applied when no per-call override is supplied. Relevant for
/// multi-path CNCs; single-path controllers leave this at the default of <c>1</c>.
/// </summary>
public ushort PathId { get; set; } = 1;
/// <summary>True when the two-socket handshake has completed and the transport is live.</summary>
public bool IsConnected => _connected;
/// <summary>
/// Open the FOCAS session using an integer-seconds timeout. Idempotent — a second
/// call while already connected is a no-op. Sub-second timeouts require the
/// <see cref="ConnectAsync(string, int, TimeSpan, CancellationToken)"/> overload.
/// </summary>
public Task ConnectAsync(
string host,
int port,
int timeoutSeconds = 10,
CancellationToken cancellationToken = default)
=> ConnectCoreAsync(
host,
port,
timeoutSeconds > 0 ? TimeSpan.FromSeconds(timeoutSeconds) : null,
cancellationToken);
/// <summary>
/// Open the FOCAS session with a <see cref="TimeSpan"/> timeout. Pass
/// <see cref="TimeSpan.Zero"/> to disable the timeout entirely (rely on the caller's
/// <paramref name="cancellationToken"/> instead). Idempotent.
/// </summary>
public Task ConnectAsync(
string host,
int port,
TimeSpan timeout,
CancellationToken cancellationToken = default)
=> ConnectCoreAsync(host, port, timeout == TimeSpan.Zero ? null : timeout, cancellationToken);
private async Task ConnectCoreAsync(
string host,
int port,
TimeSpan? timeoutValue,
CancellationToken cancellationToken)
{
await _lifetimeGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
ThrowIfDisposed();
if (_connected) return;
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
if (timeoutValue is { } value) timeout.CancelAfter(value);
try
{
_socket1 = await ConnectSocketAsync(host, port, timeout.Token).ConfigureAwait(false);
_stream1 = _socket1.GetStream();
await SendPduAsync(_stream1, FocasWireProtocol.TypeInitiate, FocasWireProtocol.BuildInitiateBody(1), timeout.Token).ConfigureAwait(false);
await ReadExpectedPduAsync(_stream1, FocasWireProtocol.TypeInitiate, timeout.Token).ConfigureAwait(false);
_socket2 = await ConnectSocketAsync(host, port, timeout.Token).ConfigureAwait(false);
_stream2 = _socket2.GetStream();
await SendPduAsync(_stream2, FocasWireProtocol.TypeInitiate, FocasWireProtocol.BuildInitiateBody(2), timeout.Token).ConfigureAwait(false);
await ReadExpectedPduAsync(_stream2, FocasWireProtocol.TypeInitiate, timeout.Token).ConfigureAwait(false);
_connected = true;
// Cache the sysinfo payload from the setup exchange so later
// ReadSysInfoAsync calls are a lookup rather than a wire hit.
var sysInfoBlock = await SendSingleRequestAsync(timeout.Token, new RequestBlock(0x0018, PathId: PathId)).ConfigureAwait(false);
_sysInfo = ToResult(sysInfoBlock, ParseSysInfo);
// Kick the cached path/session metadata request the DLL sends
// right after initiate. The result is ignored; the CNC uses it to
// populate internal state the subsequent reads depend on.
await SendRequestAsync(timeout.Token, new RequestBlock(0x000e, 0x26f0, 0x26f0, PathId: PathId)).ConfigureAwait(false);
}
catch (Exception ex) when (IsTransientException(ex))
{
CloseTransport();
throw new FocasWireException("FOCAS wire connect failed.", ex, isTransient: true);
}
}
finally
{
_lifetimeGate.Release();
}
}
/// <summary>
/// Synchronous dispose — sends the close PDU when connected and tears down both
/// sockets. Idempotent. Callers on an async context should prefer
/// <see cref="DisposeAsync"/>.
/// </summary>
public void Dispose()
{
_lifetimeGate.Wait();
try
{
if (_disposed) return;
_disposed = true;
if (_stream2 is not null && _connected)
{
try
{
SendPdu(_stream2, FocasWireProtocol.TypeClose, ReadOnlySpan<byte>.Empty);
_ = FocasWireProtocol.ReadPdu(_stream2);
}
catch
{
// Close best-effort — don't let teardown failure hide a caller's real error.
}
}
CloseTransport();
}
finally
{
_lifetimeGate.Release();
}
}
/// <summary>
/// Async dispose — sends the close PDU when connected and tears down both sockets.
/// Idempotent.
/// </summary>
public async ValueTask DisposeAsync()
{
await _lifetimeGate.WaitAsync(CancellationToken.None).ConfigureAwait(false);
try
{
if (_disposed) return;
_disposed = true;
if (_stream2 is not null && _connected)
{
try
{
await SendPduAsync(_stream2, FocasWireProtocol.TypeClose, ReadOnlyMemory<byte>.Empty, CancellationToken.None).ConfigureAwait(false);
await FocasWireProtocol.ReadPduAsync(_stream2, CancellationToken.None).ConfigureAwait(false);
}
catch
{
// Close best-effort — don't let teardown failure hide a caller's real error.
}
}
CloseTransport();
}
finally
{
_lifetimeGate.Release();
}
}
/// <summary>
/// Read CNC identity via <c>cnc_sysinfo</c>. Cached from the connect-time exchange
/// unless a per-call <paramref name="pathId"/> override is supplied.
/// </summary>
public async Task<FocasResult<WireSysInfo>> ReadSysInfoAsync(
CancellationToken cancellationToken = default,
TimeSpan? timeout = null,
ushort? pathId = null)
{
if (pathId is null && _sysInfo is { } cached) return cached;
using var callTimeout = CreateCallTimeout(cancellationToken, timeout);
return await ReadSingleAsync(0x0018, ParseSysInfo, EffectivePathId(pathId), cancellationToken: callTimeout.Token).ConfigureAwait(false);
}
/// <summary>Read CNC status bits via <c>cnc_statinfo</c> (3 command blocks aggregated into one <see cref="WireStatus"/>).</summary>
public async Task<FocasResult<WireStatus>> ReadStatusAsync(
CancellationToken cancellationToken = default,
TimeSpan? timeout = null,
ushort? pathId = null)
{
using var callTimeout = CreateCallTimeout(cancellationToken, timeout);
var requestPathId = EffectivePathId(pathId);
var blocks = await SendRequestAsync(
callTimeout.Token,
new RequestBlock(0x0019, PathId: requestPathId),
new RequestBlock(0x00e1, PathId: requestPathId),
new RequestBlock(0x0098, PathId: requestPathId)).ConfigureAwait(false);
var rc = AggregateRc(blocks);
if (rc != 0) return new FocasResult<WireStatus>(rc, null);
var primary = FindPayload(blocks, 0x0019);
RequireLength(primary, 14, "cnc_statinfo");
var tmModePayload = FindPayload(blocks, 0x0098);
var tmMode = tmModePayload.Length >= 2 ? ReadInt16(tmModePayload, 0) : (short)0;
return new FocasResult<WireStatus>(
rc,
new WireStatus(
Auto: ReadInt16(primary, 0),
Run: ReadInt16(primary, 2),
Motion: ReadInt16(primary, 4),
Mstb: ReadInt16(primary, 6),
Emergency: ReadInt16(primary, 8),
Alarm: ReadInt16(primary, 10),
Edit: ReadInt16(primary, 12),
TmMode: tmMode));
}
/// <summary>Read configured axis names via <c>cnc_rdaxisname</c> (command <c>0x0089</c>).</summary>
public async Task<FocasResult<IReadOnlyList<WireAxisRecord>>> ReadAxisNamesAsync(
short maxCount = 32,
CancellationToken cancellationToken = default,
TimeSpan? timeout = null,
ushort? pathId = null)
{
using var callTimeout = CreateCallTimeout(cancellationToken, timeout);
var block = await SendSingleRequestAsync(callTimeout.Token, new RequestBlock(0x0089, PathId: EffectivePathId(pathId))).ConfigureAwait(false);
return ToResult(block, payload => ReadNameRecords(payload, maxCount, (index, name) => new WireAxisRecord(index, name)));
}
/// <summary>Read configured spindle names via <c>cnc_rdspdlname</c> (command <c>0x008a</c>).</summary>
public async Task<FocasResult<IReadOnlyList<WireSpindleRecord>>> ReadSpindleNamesAsync(
short maxCount = 8,
CancellationToken cancellationToken = default,
TimeSpan? timeout = null,
ushort? pathId = null)
{
using var callTimeout = CreateCallTimeout(cancellationToken, timeout);
var block = await SendSingleRequestAsync(callTimeout.Token, new RequestBlock(0x008a, PathId: EffectivePathId(pathId))).ConfigureAwait(false);
return ToResult(block, payload => ReadNameRecords(payload, maxCount, (index, name) => new WireSpindleRecord(index, name)));
}
/// <summary>
/// Fast-poll bundle for one axis via <c>cnc_rddynamic2</c>. Sends 9 request blocks in
/// one PDU and aggregates the replies — alarm flags, program/sequence numbers, feed
/// and spindle actuals, plus the four-slot position quadruple.
/// </summary>
public async Task<FocasResult<WireDynamic>> ReadDynamic2Async(
short axis = 1,
CancellationToken cancellationToken = default,
TimeSpan? timeout = null,
ushort? pathId = null)
{
using var callTimeout = CreateCallTimeout(cancellationToken, timeout);
var requestPathId = EffectivePathId(pathId);
var blocks = await SendRequestAsync(
callTimeout.Token,
new RequestBlock(0x001a, PathId: requestPathId),
new RequestBlock(0x001c, PathId: requestPathId),
new RequestBlock(0x001d, PathId: requestPathId),
new RequestBlock(0x0024, PathId: requestPathId),
new RequestBlock(0x0025, PathId: requestPathId),
new RequestBlock(0x0026, 4, axis, PathId: requestPathId),
new RequestBlock(0x0026, 1, axis, PathId: requestPathId),
new RequestBlock(0x0026, 6, axis, PathId: requestPathId),
new RequestBlock(0x0026, 7, axis, PathId: requestPathId)).ConfigureAwait(false);
var rc = AggregateRc(blocks);
if (rc != 0) return new FocasResult<WireDynamic>(rc, null);
var programPayload = FindPayload(blocks, 0x001c);
return new FocasResult<WireDynamic>(
rc,
new WireDynamic(
ReadFirstInt32(blocks, 0x001a),
programPayload.Length >= 4 ? ReadInt32(programPayload, 0) : 0,
programPayload.Length >= 8 ? ReadInt32(programPayload, 4) : 0,
ReadFirstInt32(blocks, 0x001d),
ReadFirstInt32(blocks, 0x0024),
ReadFirstInt32(blocks, 0x0025),
new WireAxisPosition(
ReadSelectorPosition(blocks, 0x0026, 0),
ReadSelectorPosition(blocks, 0x0026, 1),
ReadSelectorPosition(blocks, 0x0026, 2),
ReadSelectorPosition(blocks, 0x0026, 3))));
}
/// <summary>Read servo-meter load percentages via <c>cnc_rdsvmeter</c> (command <c>0x0056</c>).</summary>
public async Task<FocasResult<IReadOnlyList<WireServoMeter>>> ReadServoMeterAsync(
short maxCount = 32,
CancellationToken cancellationToken = default,
TimeSpan? timeout = null,
ushort? pathId = null)
{
using var callTimeout = CreateCallTimeout(cancellationToken, timeout);
var requestPathId = EffectivePathId(pathId);
var blocks = await SendRequestAsync(
callTimeout.Token,
new RequestBlock(0x0056, 1, PathId: requestPathId),
new RequestBlock(0x0089, PathId: requestPathId)).ConfigureAwait(false);
var rc = AggregateRc(blocks);
if (rc != 0) return new FocasResult<IReadOnlyList<WireServoMeter>>(rc, null);
var payload = FindPayload(blocks, 0x0056);
var result = new List<WireServoMeter>();
for (var offset = 0; offset + 12 <= payload.Length && result.Count < maxCount; offset += 12)
{
var name = FocasWireProtocol.ReadNameRecord(payload.AsSpan(offset + 8, 4));
result.Add(new WireServoMeter(
(short)(result.Count + 1),
name,
ReadInt32(payload, offset),
ReadInt16(payload, offset + 4),
ReadInt16(payload, offset + 6)));
}
return new FocasResult<IReadOnlyList<WireServoMeter>>(rc, result);
}
/// <summary>Read per-spindle load percentages via <c>cnc_rdspload</c> (command <c>0x0040</c> with arg1=0).</summary>
public Task<FocasResult<IReadOnlyList<WireSpindleMetric>>> ReadSpindleLoadAsync(
short spindleSelector = -1,
CancellationToken cancellationToken = default,
TimeSpan? timeout = null,
ushort? pathId = null)
=> ReadSpindleMetricAsync(0, spindleSelector, cancellationToken, timeout, pathId);
/// <summary>Read per-spindle maximum RPMs via <c>cnc_rdspmaxrpm</c> (command <c>0x0040</c> with arg1=1).</summary>
public Task<FocasResult<IReadOnlyList<WireSpindleMetric>>> ReadSpindleMaxRpmAsync(
short spindleSelector = -1,
CancellationToken cancellationToken = default,
TimeSpan? timeout = null,
ushort? pathId = null)
=> ReadSpindleMetricAsync(1, spindleSelector, cancellationToken, timeout, pathId);
/// <summary>
/// Raw-bytes parameter read via <c>cnc_rdparam</c>. Caller marshals the returned
/// payload to the type declared in the per-series parameter catalog. <paramref name="axis"/>
/// selects an axis-scoped parameter; <c>0</c> means global.
/// </summary>
public async Task<FocasResult<byte[]>> ReadParameterBytesAsync(
short dataNumber,
short axis = 0,
CancellationToken cancellationToken = default,
TimeSpan? timeout = null,
ushort? pathId = null)
{
using var callTimeout = CreateCallTimeout(cancellationToken, timeout);
var secondArg = axis == 0 ? dataNumber : axis;
var block = await SendSingleRequestAsync(callTimeout.Token, new RequestBlock(0x000e, dataNumber, secondArg, PathId: EffectivePathId(pathId))).ConfigureAwait(false);
return ToResult(block, payload => payload);
}
/// <summary>Typed Int32 parameter read — convenience over <see cref="ReadParameterBytesAsync"/>.</summary>
public async Task<FocasResult<WireParameter>> ReadParameterAsync(
short dataNumber,
short type = 0,
CancellationToken cancellationToken = default,
TimeSpan? timeout = null,
ushort? pathId = null)
{
var result = await ReadParameterBytesAsync(dataNumber, cancellationToken: cancellationToken, timeout: timeout, pathId: pathId).ConfigureAwait(false);
if (!result.IsOk || result.Value is null) return new FocasResult<WireParameter>(result.Rc, null);
return new FocasResult<WireParameter>(
result.Rc,
new WireParameter(dataNumber, type, result.Value.Length >= 4 ? ReadInt32(result.Value, 0) : 0));
}
/// <summary>Typed 8-bit parameter read.</summary>
public async Task<FocasResult<byte>> ReadParameterByteAsync(short dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null)
{
var result = await ReadParameterBytesAsync(dataNumber, axis, cancellationToken, timeout, pathId).ConfigureAwait(false);
return !result.IsOk || result.Value is null
? new FocasResult<byte>(result.Rc, default)
: new FocasResult<byte>(result.Rc, result.Value.Length >= 1 ? result.Value[0] : default);
}
/// <summary>Typed 16-bit parameter read.</summary>
public async Task<FocasResult<short>> ReadParameterInt16Async(short dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null)
{
var result = await ReadParameterBytesAsync(dataNumber, axis, cancellationToken, timeout, pathId).ConfigureAwait(false);
return !result.IsOk || result.Value is null
? new FocasResult<short>(result.Rc, default)
: new FocasResult<short>(result.Rc, result.Value.Length >= 2 ? ReadInt16(result.Value, 0) : default);
}
/// <summary>Typed 32-bit parameter read.</summary>
public async Task<FocasResult<int>> ReadParameterInt32Async(short dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null)
{
var result = await ReadParameterBytesAsync(dataNumber, axis, cancellationToken, timeout, pathId).ConfigureAwait(false);
return !result.IsOk || result.Value is null
? new FocasResult<int>(result.Rc, default)
: new FocasResult<int>(result.Rc, result.Value.Length >= 4 ? ReadInt32(result.Value, 0) : default);
}
/// <summary>Typed IEEE-754 single-precision parameter read.</summary>
public async Task<FocasResult<float>> ReadParameterFloat32Async(short dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null)
{
var result = await ReadParameterBytesAsync(dataNumber, axis, cancellationToken, timeout, pathId).ConfigureAwait(false);
return !result.IsOk || result.Value is null || result.Value.Length < 4
? new FocasResult<float>(result.Rc, default)
: new FocasResult<float>(result.Rc, BitConverter.Int32BitsToSingle(ReadInt32(result.Value, 0)));
}
/// <summary>Typed IEEE-754 double-precision parameter read.</summary>
public async Task<FocasResult<double>> ReadParameterFloat64Async(short dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null)
{
var result = await ReadParameterBytesAsync(dataNumber, axis, cancellationToken, timeout, pathId).ConfigureAwait(false);
return !result.IsOk || result.Value is null || result.Value.Length < 8
? new FocasResult<double>(result.Rc, default)
: new FocasResult<double>(result.Rc, BitConverter.Int64BitsToDouble(BinaryPrimitives.ReadInt64BigEndian(result.Value.AsSpan(0, 8))));
}
/// <summary>Read a single macro variable via <c>cnc_rdmacro</c> (command <c>0x0015</c>).</summary>
public Task<FocasResult<WireMacro>> ReadMacroAsync(
short number,
CancellationToken cancellationToken = default,
TimeSpan? timeout = null,
ushort? pathId = null)
=> ReadSingleWithTimeoutAsync(
0x0015,
payload => new WireMacro(number, payload.Length >= 4 ? ReadInt32(payload, 0) : 0, payload.Length >= 6 ? ReadInt16(payload, 4) : (short)0),
cancellationToken, timeout, EffectivePathId(pathId), number, number);
/// <summary>
/// Read a PMC range via <c>pmc_rdpmcrng</c>. <paramref name="area"/> is the numeric
/// address-letter code (see <see cref="FocasPmcArea"/>); <paramref name="dataType"/>
/// is the width code (see <see cref="FocasPmcDataType"/>). Payload is decoded into
/// <see cref="WirePmcRange.Values"/> — one entry per slot of the requested width.
/// </summary>
public async Task<FocasResult<WirePmcRange>> ReadPmcRangeAsync(
short area,
short dataType,
ushort start,
ushort end,
CancellationToken cancellationToken = default,
TimeSpan? timeout = null,
ushort? pathId = null)
{
if (end < start)
throw new ArgumentOutOfRangeException(nameof(end), "PMC end address must be greater than or equal to start.");
using var callTimeout = CreateCallTimeout(cancellationToken, timeout);
var block = await SendSingleRequestAsync(
callTimeout.Token,
new RequestBlock(0x8001, start, end, area, dataType, RequestClass: 2, PathId: EffectivePathId(pathId))).ConfigureAwait(false);
return ToResult(block, payload =>
{
var width = dataType switch
{
1 => 2,
2 or 4 => 4,
5 => 8,
_ => 1,
};
var values = new List<long>();
for (var offset = 0; offset + width <= payload.Length; offset += width)
{
values.Add(width switch
{
1 => payload[offset],
2 => ReadInt16(payload, offset),
4 => ReadInt32(payload, offset),
8 => BinaryPrimitives.ReadInt64BigEndian(payload.AsSpan(offset, 8)),
_ => 0,
});
}
return new WirePmcRange(area, dataType, start, end, values);
});
}
/// <summary>Typed overload for <see cref="ReadPmcRangeAsync(short, short, ushort, ushort, CancellationToken, TimeSpan?, ushort?)"/>.</summary>
public Task<FocasResult<WirePmcRange>> ReadPmcRangeAsync(
FocasPmcArea area,
FocasPmcDataType dataType,
ushort start,
ushort end,
CancellationToken cancellationToken = default,
TimeSpan? timeout = null,
ushort? pathId = null)
=> ReadPmcRangeAsync((short)area, (short)dataType, start, end, cancellationToken, timeout, pathId);
/// <summary>
/// Read active alarms via <c>cnc_rdalmmsg2</c> (command <c>0x0023</c>). Parses both
/// the 76-byte vendor <c>ODBALMMSG2_data</c> layout and the 80-byte legacy wire
/// shape so the same managed surface works across firmware revisions.
/// </summary>
public async Task<FocasResult<IReadOnlyList<WireAlarm>>> ReadAlarmsAsync(
short type = -1,
short count = 32,
CancellationToken cancellationToken = default,
TimeSpan? timeout = null,
ushort? pathId = null)
{
using var callTimeout = CreateCallTimeout(cancellationToken, timeout);
var block = await SendSingleRequestAsync(
callTimeout.Token,
new RequestBlock(0x0023, type, count, 2, 0x40, PathId: EffectivePathId(pathId))).ConfigureAwait(false);
return ToResult(block, payload => ParseAlarms(payload, count));
}
/// <summary>Read operation mode via <c>cnc_rdopmode</c>, returned as the typed <see cref="FocasOperationMode"/>.</summary>
public Task<FocasResult<FocasOperationMode>> ReadOperationModeAsync(
CancellationToken cancellationToken = default,
TimeSpan? timeout = null,
ushort? pathId = null)
=> ReadSingleWithTimeoutAsync(
0x0057,
payload => (FocasOperationMode)(payload.Length >= 2 ? ReadInt16(payload, 0) : (short)0),
cancellationToken, timeout, EffectivePathId(pathId));
/// <summary>
/// Raw-code variant of <see cref="ReadOperationModeAsync"/> — returns the underlying
/// FOCAS <c>short</c> so callers storing the raw mode code (e.g. OtOpcUa's
/// <c>FocasProgramInfo.Mode</c> int field) don't have to cast the enum.
/// </summary>
public Task<FocasResult<short>> ReadOperationModeCodeAsync(
CancellationToken cancellationToken = default,
TimeSpan? timeout = null,
ushort? pathId = null)
=> ReadSingleWithTimeoutAsync(
0x0057,
payload => payload.Length >= 2 ? ReadInt16(payload, 0) : (short)0,
cancellationToken, timeout, EffectivePathId(pathId));
/// <summary>Read the currently-executing program name + O-number via <c>cnc_exeprgname2</c> (command <c>0x00fc</c>).</summary>
public Task<FocasResult<WireProgramName>> ReadExecutingProgramNameAsync(
CancellationToken cancellationToken = default,
TimeSpan? timeout = null,
ushort? pathId = null)
=> ReadSingleWithTimeoutAsync(0x00fc, ParseProgramName, cancellationToken, timeout, EffectivePathId(pathId));
/// <summary>Read the executed block count via <c>cnc_rdblkcount</c>.</summary>
public Task<FocasResult<int>> ReadBlockCountAsync(
CancellationToken cancellationToken = default,
TimeSpan? timeout = null,
ushort? pathId = null)
=> ReadSingleWithTimeoutAsync(
0x0035,
payload => payload.Length >= 4 ? ReadInt32(payload, 0) : 0,
cancellationToken, timeout, EffectivePathId(pathId));
/// <summary>
/// Read one cumulative timer via <c>cnc_rdtimer</c>. <paramref name="type"/> selects
/// PowerOn / Operating / Cutting / Cycle per the FOCAS spec (0..3).
/// </summary>
public Task<FocasResult<WireTimer>> ReadTimerAsync(
short type,
CancellationToken cancellationToken = default,
TimeSpan? timeout = null,
ushort? pathId = null)
=> ReadSingleWithTimeoutAsync(
0x0120,
payload => new WireTimer(type, payload.Length >= 4 ? ReadInt32(payload, 0) : 0, payload.Length >= 8 ? ReadInt32(payload, 4) : 0),
cancellationToken, timeout, EffectivePathId(pathId), type);
// ---- internal plumbing ------------------------------------------------------------
private async Task<FocasResult<IReadOnlyList<WireSpindleMetric>>> ReadSpindleMetricAsync(
int metric, short spindleSelector, CancellationToken cancellationToken, TimeSpan? timeout, ushort? pathId)
{
using var callTimeout = CreateCallTimeout(cancellationToken, timeout);
var block = await SendSingleRequestAsync(
callTimeout.Token,
new RequestBlock(0x0040, metric, spindleSelector, PathId: EffectivePathId(pathId))).ConfigureAwait(false);
return ToResult<IReadOnlyList<WireSpindleMetric>>(block, payload =>
{
var values = new List<WireSpindleMetric>();
for (var offset = 0; offset + 8 <= payload.Length; offset += 8)
values.Add(new WireSpindleMetric((short)(values.Count + 1), ReadInt32(payload, offset)));
return values;
});
}
private async Task<FocasResult<T>> ReadSingleAsync<T>(
ushort command,
Func<byte[], T> parser,
ushort? pathId = null,
int arg1 = 0,
int arg2 = 0,
int arg3 = 0,
int arg4 = 0,
CancellationToken cancellationToken = default)
{
var block = await SendSingleRequestAsync(cancellationToken, new RequestBlock(command, arg1, arg2, arg3, arg4, PathId: EffectivePathId(pathId))).ConfigureAwait(false);
return ToResult(block, parser);
}
private async Task<FocasResult<T>> ReadSingleWithTimeoutAsync<T>(
ushort command,
Func<byte[], T> parser,
CancellationToken cancellationToken,
TimeSpan? timeout,
ushort pathId,
int arg1 = 0,
int arg2 = 0,
int arg3 = 0,
int arg4 = 0)
{
using var callTimeout = CreateCallTimeout(cancellationToken, timeout);
return await ReadSingleAsync(command, parser, pathId, arg1, arg2, arg3, arg4, callTimeout.Token).ConfigureAwait(false);
}
private async Task<ResponseBlock> SendSingleRequestAsync(CancellationToken cancellationToken, RequestBlock block)
{
var blocks = await SendRequestAsync(cancellationToken, block).ConfigureAwait(false);
return blocks.Count == 0 ? new ResponseBlock(block.Command, 0, Array.Empty<byte>()) : blocks[0];
}
private async Task<IReadOnlyList<ResponseBlock>> SendRequestAsync(CancellationToken cancellationToken, params RequestBlock[] blocks)
{
EnsureConnected();
await _requestGate.WaitAsync(cancellationToken).ConfigureAwait(false);
var requestStarted = false;
try
{
var body = FocasWireProtocol.BuildRequestBody(blocks);
requestStarted = true;
await SendPduAsync(_stream2!, FocasWireProtocol.TypeData, body, cancellationToken).ConfigureAwait(false);
var response = await ReadExpectedPduAsync(_stream2!, FocasWireProtocol.TypeData, cancellationToken).ConfigureAwait(false);
var responseBlocks = FocasWireProtocol.ParseResponseBlocks(response.Body);
foreach (var block in responseBlocks)
_logger?.LogDebug("FOCAS response command=0x{Command:x4} rc={Rc} payloadLength={PayloadLength}", block.Command, block.Rc, block.Payload.Length);
return responseBlocks;
}
catch (Exception ex) when (requestStarted && IsTransientException(ex))
{
// A cancelled or failed mid-request write leaves the wire in an undefined state —
// tear the connection down so the next caller reconnects cleanly instead of
// consuming a stale response.
CloseTransport();
throw new FocasWireException("FOCAS wire request failed; connection was closed to avoid response desynchronization.", ex, isTransient: true);
}
finally
{
_requestGate.Release();
}
}
private static async Task<TcpClient> ConnectSocketAsync(string host, int port, CancellationToken cancellationToken)
{
var socket = new TcpClient { NoDelay = true };
try
{
await WithCancellation(socket.ConnectAsync(host, port), cancellationToken).ConfigureAwait(false);
return socket;
}
catch
{
socket.Dispose();
throw;
}
}
private static async Task SendPduAsync(NetworkStream stream, byte type, ReadOnlyMemory<byte> body, CancellationToken cancellationToken)
{
var pdu = FocasWireProtocol.BuildPdu(type, FocasWireProtocol.DirectionRequest, body.Span);
await stream.WriteAsync(pdu, 0, pdu.Length, cancellationToken).ConfigureAwait(false);
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
}
private static void SendPdu(NetworkStream stream, byte type, ReadOnlySpan<byte> body)
{
var pdu = FocasWireProtocol.BuildPdu(type, FocasWireProtocol.DirectionRequest, body);
stream.Write(pdu, 0, pdu.Length);
stream.Flush();
}
private void ThrowIfDisposed()
{
if (_disposed) throw new ObjectDisposedException(nameof(FocasWireClient));
}
private static async Task WithCancellation(Task task, CancellationToken cancellationToken)
{
if (!cancellationToken.CanBeCanceled)
{
await task.ConfigureAwait(false);
return;
}
var cancellation = new TaskCompletionSource<bool>();
using var registration = cancellationToken.Register(static state => ((TaskCompletionSource<bool>)state!).TrySetResult(true), cancellation);
if (task != await Task.WhenAny(task, cancellation.Task).ConfigureAwait(false))
throw new OperationCanceledException(cancellationToken);
await task.ConfigureAwait(false);
}
private static CancellationTokenSource CreateCallTimeout(CancellationToken cancellationToken, TimeSpan? timeout)
{
var source = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
if (timeout is { } value) source.CancelAfter(value);
return source;
}
private static async Task<Pdu> ReadExpectedPduAsync(NetworkStream stream, byte expectedType, CancellationToken cancellationToken)
{
var pdu = await FocasWireProtocol.ReadPduAsync(stream, cancellationToken).ConfigureAwait(false);
if (pdu.Type != expectedType || pdu.Direction != FocasWireProtocol.DirectionResponse)
throw new FocasWireException($"Unexpected FOCAS PDU type 0x{pdu.Type:x2}, direction 0x{pdu.Direction:x2}.", rc: null);
return pdu;
}
private void EnsureConnected()
{
ThrowIfDisposed();
if (!_connected || _stream2 is null)
throw new FocasWireException("FOCAS wire client is not connected.", rc: null, isTransient: true);
}
private void CloseTransport()
{
_connected = false;
_sysInfo = null;
_stream1?.Dispose();
_stream2?.Dispose();
_socket1?.Dispose();
_socket2?.Dispose();
_stream1 = null;
_stream2 = null;
_socket1 = null;
_socket2 = null;
}
private ushort EffectivePathId(ushort? pathId) => pathId ?? PathId;
private static FocasResult<T> ToResult<T>(ResponseBlock block, Func<byte[], T> parser)
=> block.Rc != 0
? new FocasResult<T>(block.Rc, default)
: new FocasResult<T>(block.Rc, parser(block.Payload));
private static short AggregateRc(IReadOnlyList<ResponseBlock> blocks)
=> blocks.FirstOrDefault(block => block.Rc != 0)?.Rc ?? 0;
private static byte[] FindPayload(IReadOnlyList<ResponseBlock> blocks, ushort command)
=> blocks.FirstOrDefault(block => block.Command == command)?.Payload ?? Array.Empty<byte>();
private static int ReadFirstInt32(IReadOnlyList<ResponseBlock> blocks, ushort command)
{
var payload = FindPayload(blocks, command);
return payload.Length >= 4 ? ReadInt32(payload, 0) : 0;
}
private static int ReadSelectorPosition(IReadOnlyList<ResponseBlock> blocks, ushort command, int selectorIndex)
{
var seen = 0;
foreach (var block in blocks)
{
if (block.Command != command) continue;
if (seen == selectorIndex)
return block.Payload.Length >= 4 ? ReadInt32(block.Payload, 0) : 0;
seen++;
}
return 0;
}
private static WireSysInfo ParseSysInfo(byte[] payload)
{
RequireLength(payload, 16, "cnc_sysinfo");
return new WireSysInfo(
ReadInt16(payload, 0),
ReadInt16(payload, 2),
FocasWireProtocol.ReadAscii(payload.AsSpan(4, 2)),
FocasWireProtocol.ReadAscii(payload.AsSpan(6, 2)),
FocasWireProtocol.ReadAscii(payload.AsSpan(8, 4)),
FocasWireProtocol.ReadAscii(payload.AsSpan(12, 4)),
payload.Length >= 18 ? FocasWireProtocol.ReadAscii(payload.AsSpan(16, 2)) : string.Empty);
}
private static WireProgramName ParseProgramName(byte[] payload)
{
var nameLength = payload.Length >= 40 ? 36 : payload.Length;
var name = FocasWireProtocol.ReadAscii(payload.AsSpan(0, nameLength));
var number = payload.Length >= 40 ? ReadInt32(payload, 36) : (int?)null;
return new WireProgramName(name, number);
}
private static IReadOnlyList<WireAlarm> ParseAlarms(byte[] payload, short count)
=> payload.Length % 76 == 0
? ParseVendorAlarms(payload, count)
: ParseLegacyWireAlarms(payload, count);
private static IReadOnlyList<WireAlarm> ParseVendorAlarms(byte[] payload, short count)
{
var alarms = new List<WireAlarm>();
for (var offset = 0; offset + 76 <= payload.Length && alarms.Count < count; offset += 76)
{
var messageLength = ReadInt16(payload, offset + 10);
alarms.Add(new WireAlarm(
ReadInt32(payload, offset),
ReadInt16(payload, offset + 4),
ReadInt16(payload, offset + 6),
messageLength,
FocasWireProtocol.ReadAscii(payload.AsSpan(offset + 12, 64))));
}
return alarms;
}
private static IReadOnlyList<WireAlarm> ParseLegacyWireAlarms(byte[] payload, short count)
{
var alarms = new List<WireAlarm>();
for (var offset = 0; offset + 80 <= payload.Length && alarms.Count < count; offset += 80)
{
alarms.Add(new WireAlarm(
ReadInt32(payload, offset),
(short)ReadInt32(payload, offset + 4),
(short)ReadInt32(payload, offset + 8),
(short)ReadInt32(payload, offset + 12),
FocasWireProtocol.ReadAscii(payload.AsSpan(offset + 16, 64))));
}
return alarms;
}
private static IReadOnlyList<T> ReadNameRecords<T>(byte[] payload, short maxCount, Func<short, string, T> factory)
{
var names = new List<T>();
for (var offset = 0; offset + 4 <= payload.Length && offset / 4 < maxCount; offset += 4)
{
var name = FocasWireProtocol.ReadNameRecord(payload.AsSpan(offset, 4));
if (!string.IsNullOrWhiteSpace(name))
names.Add(factory((short)((offset / 4) + 1), name));
}
return names;
}
private static void RequireLength(byte[] payload, int length, string call)
{
if (payload.Length < length)
throw new FocasWireException($"{call} returned {payload.Length} bytes; expected at least {length}.", rc: null);
}
private static bool IsTransientException(Exception exception)
=> exception is IOException or SocketException or TimeoutException or OperationCanceledException
|| exception.InnerException is IOException or SocketException or TimeoutException or OperationCanceledException;
private static short ReadInt16(byte[] bytes, int offset)
=> BinaryPrimitives.ReadInt16BigEndian(bytes.AsSpan(offset, 2));
private static int ReadInt32(byte[] bytes, int offset)
=> BinaryPrimitives.ReadInt32BigEndian(bytes.AsSpan(offset, 4));
}

View File

@@ -0,0 +1,51 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
/// <summary>
/// Thrown by the wire client when a FOCAS request fails — either at the protocol layer
/// (invalid PDU magic, desynchronised response framing, connection dropped mid-request)
/// or when the CNC returns a non-zero <c>EW_*</c> return code.
/// </summary>
/// <remarks>
/// <para>Callers distinguish the two classes via <see cref="IsTransient"/>: <c>true</c>
/// when the transport is gone (socket closed, timeout, cancellation mid-write) and the
/// wire client has already torn the sockets down, so a reconnect is required before any
/// further call. <c>false</c> for protocol-level errors where the connection is still
/// usable.</para>
/// <para><see cref="Rc"/> carries the wire-level FOCAS return code when the exception
/// came from a parsed response block. Null when the failure happened before a response
/// was received (e.g. connect-time handshake errors).</para>
/// </remarks>
public class FocasWireException : Exception
{
/// <summary>FOCAS <c>EW_*</c> return code from the response block, when available.</summary>
public short? Rc { get; }
/// <summary>
/// True when the transport was closed as a side effect of this failure — the caller
/// must reconnect before issuing the next request.
/// </summary>
public bool IsTransient { get; }
public FocasWireException(string message)
: base(message)
{
}
public FocasWireException(string message, short? rc, bool isTransient = false)
: base(message)
{
Rc = rc;
IsTransient = isTransient;
}
public FocasWireException(string message, Exception innerException)
: base(message, innerException)
{
}
public FocasWireException(string message, Exception innerException, bool isTransient)
: base(message, innerException)
{
IsTransient = isTransient;
}
}

View File

@@ -0,0 +1,131 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
/// <summary>
/// Return envelope over a parsed wire response. <see cref="Rc"/> carries the FOCAS
/// <c>EW_*</c> code from the response block — <c>0</c> / <see cref="IsOk"/> means the
/// call succeeded and <see cref="Value"/> is populated; non-zero means the CNC rejected
/// the call and <see cref="Value"/> is <c>default</c>. Callers use the RC to distinguish
/// "feature missing on this series" (<c>EW_FUNC</c> / <c>EW_NOOPT</c>) from genuine
/// failures.
/// </summary>
public readonly record struct FocasResult<T>(short Rc, T? Value)
{
/// <summary>True when <see cref="Rc"/> is zero (<c>EW_OK</c>).</summary>
public bool IsOk => Rc == 0;
}
/// <summary>CNC identity payload returned by <c>cnc_sysinfo</c>.</summary>
public sealed record WireSysInfo(
short AddInfo,
short MaxAxis,
string CncType,
string MachineType,
string Series,
string Version,
string Axes);
/// <summary>Coarse CNC state bits returned by <c>cnc_statinfo</c> — the seven-word status block plus TM mode.</summary>
public sealed record WireStatus(
short Auto,
short Run,
short Motion,
short Mstb,
short Emergency,
short Alarm,
short Edit,
short TmMode);
/// <summary>Four-slot position quadruple for one axis: absolute / machine / relative / distance-to-go.</summary>
public sealed record WireAxisPosition(
int Absolute,
int Machine,
int Relative,
int Distance);
/// <summary>
/// Fast-poll bundle for one axis from <c>cnc_rddynamic2</c> — alarm flags, active program
/// numbers, sequence number, actual feed rate, actual spindle speed, and the position
/// quadruple.
/// </summary>
public sealed record WireDynamic(
int Alarm,
int ProgramNumber,
int MainProgramNumber,
int SequenceNumber,
int FeedRate,
int SpindleSpeed,
WireAxisPosition Axis);
/// <summary>One servo-meter entry from <c>cnc_rdsvmeter</c> — per-axis load percentage (scale by 10^<see cref="Decimal"/>).</summary>
public sealed record WireServoMeter(
short Index,
string Name,
int Value,
short Decimal,
short Unit);
/// <summary>One spindle metric slot from <c>cnc_rdspload</c> / <c>cnc_rdspmaxrpm</c>.</summary>
public sealed record WireSpindleMetric(
short Index,
int Value);
/// <summary>
/// One axis-name slot from <c>cnc_rdaxisname</c>. <see cref="Index"/> is the 1-based
/// axis index (preserved even when the name is empty so callers can pass it to
/// <c>cnc_rddynamic2</c>).
/// </summary>
public readonly record struct WireAxisRecord(short Index, string Name);
/// <summary>One spindle-name slot from <c>cnc_rdspdlname</c>.</summary>
public readonly record struct WireSpindleRecord(short Index, string Name);
/// <summary>Parameter value returned by <c>cnc_rdparam</c>, interpreted as a scalar Int32.</summary>
public sealed record WireParameter(
short DataNumber,
short Type,
int Value);
/// <summary>
/// Macro variable from <c>cnc_rdmacro</c>. Scaled decimal: the callable value is
/// <c>Value / 10^Decimal</c>.
/// </summary>
public sealed record WireMacro(
short Number,
int Value,
short Decimal);
/// <summary>PMC range read-back from <c>pmc_rdpmcrng</c>: one or more values of the requested width.</summary>
public sealed record WirePmcRange(
short Area,
short DataType,
ushort Start,
ushort End,
IReadOnlyList<long> Values);
/// <summary>
/// One active alarm from <c>cnc_rdalmmsg2</c>. Mirrors the vendor <c>ODBALMMSG2</c>
/// layout; <see cref="AlarmGroup"/> is populated when the wire responder carries it
/// (currently <c>null</c> for both the 76-byte vendor shape and the 80-byte legacy
/// shape).
/// </summary>
public sealed record WireAlarm(
int AlarmNumber,
short Type,
short Axis,
short MessageLength,
string Message,
int? AlarmGroup = null);
/// <summary>
/// Executing-program identity from <c>cnc_exeprgname2</c>: the NUL-terminated name and
/// the trailing 32-bit O-number (null when the wire responder omits the trailing int).
/// </summary>
public sealed record WireProgramName(
string Name,
int? ONumber);
/// <summary>One cumulative timer reading from <c>cnc_rdtimer</c> (minutes + fractional milliseconds).</summary>
public sealed record WireTimer(
short Type,
int Minutes,
int Milliseconds);

View File

@@ -0,0 +1,250 @@
using System.Buffers.Binary;
using System.Net.Sockets;
using System.Text;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
/// <summary>
/// Framing primitives for the FOCAS/2 Ethernet wire protocol — magic-prefixed PDU
/// header + request/response block envelopes. Read-only subset: every call OtOpcUa
/// issues maps to one of the command IDs documented in
/// <c>docs/v2/implementation/focas-wire-protocol.md</c>.
/// </summary>
/// <remarks>
/// <para>All multi-byte integer fields are big-endian on the wire. The 10-byte header is
/// <c>a0 a0 a0 a0</c> magic + 2-byte version + type byte + direction byte + 2-byte body
/// length. Version 1 is the only version this implementation supports.</para>
/// <para>Type <c>0x01</c> is the initiate handshake, <c>0x02</c> is the session close,
/// <c>0x21</c> is a request/response data PDU carrying one or more request blocks.</para>
/// </remarks>
internal static class FocasWireProtocol
{
public const ushort Version = 1;
public const byte DirectionRequest = 0x01;
public const byte DirectionResponse = 0x02;
public const byte TypeInitiate = 0x01;
public const byte TypeClose = 0x02;
public const byte TypeData = 0x21;
private static readonly byte[] Magic = [0xa0, 0xa0, 0xa0, 0xa0];
/// <summary>Assemble a full PDU (10-byte header + body) for transmission.</summary>
public static byte[] BuildPdu(byte type, byte direction, ReadOnlySpan<byte> body)
{
if (body.Length > ushort.MaxValue)
throw new ArgumentOutOfRangeException(nameof(body), "FOCAS PDU body is limited to 65535 bytes.");
var bytes = new byte[10 + body.Length];
Magic.CopyTo(bytes, 0);
BinaryPrimitives.WriteUInt16BigEndian(bytes.AsSpan(4, 2), Version);
bytes[6] = type;
bytes[7] = direction;
BinaryPrimitives.WriteUInt16BigEndian(bytes.AsSpan(8, 2), (ushort)body.Length);
body.CopyTo(bytes.AsSpan(10));
return bytes;
}
/// <summary>
/// Initiate-body shape — just the 2-byte socket index (1 or 2). <c>cnc_allclibhndl3</c>
/// opens two TCP sockets in sequence and each sends its own initiate PDU carrying its
/// index.
/// </summary>
public static byte[] BuildInitiateBody(ushort socketIndex)
{
var body = new byte[2];
BinaryPrimitives.WriteUInt16BigEndian(body, socketIndex);
return body;
}
/// <summary>Assemble a type-<c>0x21</c> body carrying one or more request blocks.</summary>
public static byte[] BuildRequestBody(IReadOnlyList<RequestBlock> blocks)
{
if (blocks.Count > ushort.MaxValue)
throw new ArgumentOutOfRangeException(nameof(blocks), "Too many request blocks.");
var blockBytes = blocks.Select(BuildRequestBlock).ToArray();
var bodyLength = 2 + blockBytes.Sum(block => block.Length);
if (bodyLength > ushort.MaxValue)
throw new ArgumentOutOfRangeException(nameof(blocks), "FOCAS request body is too large.");
var body = new byte[bodyLength];
BinaryPrimitives.WriteUInt16BigEndian(body.AsSpan(0, 2), (ushort)blocks.Count);
var offset = 2;
foreach (var block in blockBytes)
{
block.CopyTo(body.AsSpan(offset));
offset += block.Length;
}
return body;
}
/// <summary>Async read of one full PDU off a stream. Throws <see cref="FocasWireException"/> on invalid magic / version / truncation.</summary>
public static async Task<Pdu> ReadPduAsync(NetworkStream stream, CancellationToken cancellationToken)
{
var header = new byte[10];
await ReadExactlyAsync(stream, header, cancellationToken).ConfigureAwait(false);
if (!header.AsSpan(0, 4).SequenceEqual(Magic))
throw new FocasWireException("Invalid FOCAS PDU magic.");
var version = BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(4, 2));
if (version != Version)
throw new FocasWireException($"Unsupported FOCAS PDU version {version}.");
var bodyLength = BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(8, 2));
var body = new byte[bodyLength];
if (bodyLength > 0)
await ReadExactlyAsync(stream, body, cancellationToken).ConfigureAwait(false);
return new Pdu(header[6], header[7], body);
}
/// <summary>Synchronous counterpart to <see cref="ReadPduAsync"/> — used by <see cref="FocasWireClient"/>'s sync dispose.</summary>
public static Pdu ReadPdu(NetworkStream stream)
{
var header = new byte[10];
ReadExactly(stream, header);
if (!header.AsSpan(0, 4).SequenceEqual(Magic))
throw new FocasWireException("Invalid FOCAS PDU magic.");
var version = BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(4, 2));
if (version != Version)
throw new FocasWireException($"Unsupported FOCAS PDU version {version}.");
var bodyLength = BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(8, 2));
var body = new byte[bodyLength];
if (bodyLength > 0)
ReadExactly(stream, body);
return new Pdu(header[6], header[7], body);
}
private static async Task ReadExactlyAsync(NetworkStream stream, byte[] buffer, CancellationToken cancellationToken)
{
var offset = 0;
while (offset < buffer.Length)
{
var read = await stream.ReadAsync(buffer, offset, buffer.Length - offset, cancellationToken).ConfigureAwait(false);
if (read == 0)
throw new EndOfStreamException("FOCAS socket closed before the expected number of bytes were read.");
offset += read;
}
}
private static void ReadExactly(NetworkStream stream, byte[] buffer)
{
var offset = 0;
while (offset < buffer.Length)
{
var read = stream.Read(buffer, offset, buffer.Length - offset);
if (read == 0)
throw new EndOfStreamException("FOCAS socket closed before the expected number of bytes were read.");
offset += read;
}
}
/// <summary>
/// Unpack a type-<c>0x21</c> response body into its constituent response blocks. Each
/// block carries the command ID, the FOCAS <c>EW_*</c> return code, and the payload
/// bytes.
/// </summary>
public static IReadOnlyList<ResponseBlock> ParseResponseBlocks(ReadOnlySpan<byte> body)
{
if (body.Length < 2)
return Array.Empty<ResponseBlock>();
var count = BinaryPrimitives.ReadUInt16BigEndian(body.Slice(0, 2));
var blocks = new List<ResponseBlock>(count);
var offset = 2;
for (var index = 0; index < count; index++)
{
if (offset + 2 > body.Length)
throw new FocasWireException("Truncated FOCAS response block length.");
var blockLength = BinaryPrimitives.ReadUInt16BigEndian(body.Slice(offset, 2));
if (blockLength < 0x10 || offset + blockLength > body.Length)
throw new FocasWireException($"Invalid FOCAS response block length {blockLength}.");
var block = body.Slice(offset, blockLength);
var command = BinaryPrimitives.ReadUInt16BigEndian(block.Slice(6, 2));
var payloadLength = BinaryPrimitives.ReadUInt16BigEndian(block.Slice(14, 2));
if (0x10 + payloadLength > blockLength)
throw new FocasWireException("Invalid FOCAS response payload length.");
var rc = BinaryPrimitives.ReadInt16BigEndian(block.Slice(8, 2));
blocks.Add(new ResponseBlock(command, rc, block.Slice(16, payloadLength).ToArray()));
offset += blockLength;
}
return blocks;
}
/// <summary>Read an ASCII string out of a payload span, stopping at the first NUL and trimming trailing spaces.</summary>
public static string ReadAscii(ReadOnlySpan<byte> bytes)
{
var end = bytes.IndexOf((byte)0);
if (end >= 0) bytes = bytes.Slice(0, end);
return Encoding.ASCII.GetString(bytes.ToArray()).TrimEnd(' ', '\0');
}
/// <summary>
/// Read an axis/spindle name record — the first 2 bytes of a 2-byte (axis) or 4-byte
/// (spindle) slot. Trailing spaces and NULs are stripped so <c>"X "</c> becomes
/// <c>"X"</c>.
/// </summary>
public static string ReadNameRecord(ReadOnlySpan<byte> bytes)
{
if (bytes.Length < 2) return string.Empty;
var buffer = bytes.Slice(0, Math.Min(2, bytes.Length)).ToArray();
return Encoding.ASCII.GetString(buffer).TrimEnd(' ', '\0');
}
private static byte[] BuildRequestBlock(RequestBlock request)
{
var extra = request.ExtraPayload ?? Array.Empty<byte>();
if (extra.Length > ushort.MaxValue)
throw new ArgumentOutOfRangeException(nameof(request), "FOCAS request extra payload is too large.");
var blockLength = 0x1c + extra.Length;
if (blockLength > ushort.MaxValue)
throw new ArgumentOutOfRangeException(nameof(request), "FOCAS request block is too large.");
var block = new byte[blockLength];
BinaryPrimitives.WriteUInt16BigEndian(block.AsSpan(0, 2), (ushort)blockLength);
BinaryPrimitives.WriteUInt16BigEndian(block.AsSpan(2, 2), request.RequestClass);
BinaryPrimitives.WriteUInt16BigEndian(block.AsSpan(4, 2), request.PathId);
BinaryPrimitives.WriteUInt16BigEndian(block.AsSpan(6, 2), request.Command);
BinaryPrimitives.WriteInt32BigEndian(block.AsSpan(8, 4), request.Arg1);
BinaryPrimitives.WriteInt32BigEndian(block.AsSpan(12, 4), request.Arg2);
BinaryPrimitives.WriteInt32BigEndian(block.AsSpan(16, 4), request.Arg3);
BinaryPrimitives.WriteInt32BigEndian(block.AsSpan(20, 4), request.Arg4);
BinaryPrimitives.WriteUInt16BigEndian(block.AsSpan(24, 2), request.Arg5);
BinaryPrimitives.WriteUInt16BigEndian(block.AsSpan(26, 2), (ushort)extra.Length);
extra.CopyTo(block.AsSpan(28));
return block;
}
}
/// <summary>One raw PDU off the wire — header bytes plus the body.</summary>
internal sealed record Pdu(byte Type, byte Direction, byte[] Body);
/// <summary>
/// One request block within a type-<c>0x21</c> PDU body. <see cref="Command"/> is the
/// FOCAS command ID (e.g. <c>0x0018</c> for sysinfo); <see cref="Arg1"/>..<see cref="Arg5"/>
/// are the command-specific scalar arguments; <see cref="ExtraPayload"/> carries the
/// optional extra bytes for writes.
/// </summary>
internal sealed record RequestBlock(
ushort Command,
int Arg1 = 0,
int Arg2 = 0,
int Arg3 = 0,
int Arg4 = 0,
ushort Arg5 = 0,
ushort RequestClass = 1,
ushort PathId = 1,
byte[]? ExtraPayload = null);
/// <summary>One response block — command ID + FOCAS return code + payload bytes.</summary>
internal sealed record ResponseBlock(ushort Command, short Rc, byte[] Payload);

View File

@@ -0,0 +1,333 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
/// <summary>
/// <see cref="IFocasClient"/> implementation backed by the in-tree managed
/// <see cref="FocasWireClient"/>. No P/Invoke, no <c>Fwlib64.dll</c>, no out-of-process
/// Host — the wire client dials the CNC on TCP:8193 directly and speaks the FOCAS/2
/// Ethernet binary protocol.
/// </summary>
/// <remarks>
/// OtOpcUa is read-only against FOCAS. <see cref="WriteAsync"/> returns
/// <see cref="FocasStatusMapper.BadNotWritable"/> for every address — the managed wire
/// client intentionally does not expose <c>cnc_wrparam</c> / <c>pmc_wrpmcrng</c> /
/// <c>cnc_wrmacro</c>.
/// </remarks>
public sealed class WireFocasClient : IFocasClient
{
private readonly FocasWireClient _wire = new();
private FocasHostAddress? _address;
public bool IsConnected => _wire.IsConnected;
public async Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken cancellationToken)
{
if (_wire.IsConnected) return;
_address = address;
// FocasWireClient.ConnectAsync interprets TimeSpan.Zero as "no timeout" — clamp the
// driver's default TimeSpan to at least 1s so a caller passing TimeSpan.Zero gets a
// sane fail-fast instead of hanging indefinitely.
var effective = timeout <= TimeSpan.Zero ? TimeSpan.FromSeconds(1) : timeout;
await _wire.ConnectAsync(address.Host, address.Port, effective, cancellationToken).ConfigureAwait(false);
}
public async Task<(object? value, uint status)> ReadAsync(
FocasAddress address, FocasDataType type, CancellationToken cancellationToken)
{
if (!_wire.IsConnected) return (null, FocasStatusMapper.BadCommunicationError);
cancellationToken.ThrowIfCancellationRequested();
return address.Kind switch
{
FocasAreaKind.Pmc => await ReadPmcAsync(address, type, cancellationToken).ConfigureAwait(false),
FocasAreaKind.Parameter => await ReadParameterAsync(address, type, cancellationToken).ConfigureAwait(false),
FocasAreaKind.Macro => await ReadMacroAsync(address, cancellationToken).ConfigureAwait(false),
_ => (null, FocasStatusMapper.BadNotSupported),
};
}
public Task<uint> WriteAsync(
FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken)
=> Task.FromResult(FocasStatusMapper.BadNotWritable);
public async Task<bool> ProbeAsync(CancellationToken cancellationToken)
{
if (!_wire.IsConnected) return false;
try
{
var result = await _wire.ReadStatusAsync(cancellationToken).ConfigureAwait(false);
return result.IsOk;
}
catch (FocasWireException)
{
return false;
}
}
public async Task<IReadOnlyList<FocasActiveAlarm>> ReadAlarmsAsync(CancellationToken cancellationToken)
{
if (!_wire.IsConnected) return [];
try
{
var result = await _wire.ReadAlarmsAsync(FocasAlarmType.All, 32, cancellationToken).ConfigureAwait(false);
if (!result.IsOk || result.Value is null) return [];
return result.Value.Select(Map).ToList();
}
catch (FocasWireException)
{
return [];
}
static FocasActiveAlarm Map(WireAlarm a) => new(
AlarmNumber: a.AlarmNumber,
Type: a.Type,
Axis: a.Axis,
Message: a.Message ?? string.Empty);
}
public async Task<FocasSysInfo> GetSysInfoAsync(CancellationToken cancellationToken)
{
RequireConnected();
var result = await _wire.ReadSysInfoAsync(cancellationToken).ConfigureAwait(false);
ThrowIfRcNonZero(result.Rc, "cnc_sysinfo", result.IsOk);
var info = result.Value!;
// Fanuc right-pads the ASCII axis count with spaces; fall back to MaxAxis if the
// text field isn't interpretable as an integer.
var axesCount = int.TryParse(info.Axes?.Trim(), out var parsed) ? parsed : info.MaxAxis;
return new FocasSysInfo(
AddInfo: info.AddInfo,
MaxAxis: info.MaxAxis,
CncType: info.CncType ?? string.Empty,
MtType: info.MachineType ?? string.Empty,
Series: info.Series ?? string.Empty,
Version: info.Version ?? string.Empty,
AxesCount: axesCount);
}
public async Task<IReadOnlyList<FocasAxisName>> GetAxisNamesAsync(CancellationToken cancellationToken)
{
if (!_wire.IsConnected) return [];
var result = await _wire.ReadAxisNamesAsync(32, cancellationToken).ConfigureAwait(false);
if (!result.IsOk || result.Value is null) return [];
return result.Value.Select(SplitAxis).Where(n => n.Name.Length > 0).ToList();
// FocasWireClient returns axis records as a single Name string (e.g. "X" or "X1").
// IFocasClient wants Name + Suffix split — the first char is the axis letter, the
// rest is the multi-channel suffix.
static FocasAxisName SplitAxis(WireAxisRecord r)
{
var n = r.Name ?? string.Empty;
return n.Length == 0
? new FocasAxisName(string.Empty, string.Empty)
: new FocasAxisName(n[..1], n.Length > 1 ? n[1..] : string.Empty);
}
}
public async Task<IReadOnlyList<FocasSpindleName>> GetSpindleNamesAsync(CancellationToken cancellationToken)
{
if (!_wire.IsConnected) return [];
var result = await _wire.ReadSpindleNamesAsync(8, cancellationToken).ConfigureAwait(false);
if (!result.IsOk || result.Value is null) return [];
return result.Value.Select(SplitSpindle).Where(n => n.Name.Length > 0).ToList();
static FocasSpindleName SplitSpindle(WireSpindleRecord r)
{
var n = r.Name ?? string.Empty;
return new FocasSpindleName(
Name: n.Length > 0 ? n[..1] : string.Empty,
Suffix1: n.Length > 1 ? n[1..2] : string.Empty,
Suffix2: n.Length > 2 ? n[2..3] : string.Empty,
Suffix3: n.Length > 3 ? n[3..4] : string.Empty);
}
}
public async Task<FocasDynamicSnapshot> ReadDynamicAsync(int axisIndex, CancellationToken cancellationToken)
{
RequireConnected();
var result = await _wire.ReadDynamic2Async((short)axisIndex, cancellationToken).ConfigureAwait(false);
ThrowIfRcNonZero(result.Rc, "cnc_rddynamic2", result.IsOk);
var d = result.Value!;
var pos = d.Axis ?? new WireAxisPosition(0, 0, 0, 0);
return new FocasDynamicSnapshot(
AxisIndex: axisIndex,
AlarmFlags: d.Alarm,
ProgramNumber: d.ProgramNumber,
MainProgramNumber: d.MainProgramNumber,
SequenceNumber: d.SequenceNumber,
ActualFeedRate: d.FeedRate,
ActualSpindleSpeed: d.SpindleSpeed,
AbsolutePosition: pos.Absolute,
MachinePosition: pos.Machine,
RelativePosition: pos.Relative,
DistanceToGo: pos.Distance);
}
public async Task<FocasProgramInfo> GetProgramInfoAsync(CancellationToken cancellationToken)
{
RequireConnected();
var nameResult = await _wire.ReadExecutingProgramNameAsync(cancellationToken).ConfigureAwait(false);
var blkResult = await _wire.ReadBlockCountAsync(cancellationToken).ConfigureAwait(false);
// Use the raw short variant — FocasProgramInfo.Mode stores the integer code so the
// managed ToText path in FocasOpMode can map it for display.
var modeResult = await _wire.ReadOperationModeCodeAsync(cancellationToken).ConfigureAwait(false);
var wireName = nameResult.Value;
return new FocasProgramInfo(
Name: wireName?.Name ?? string.Empty,
ONumber: wireName?.ONumber ?? 0,
BlockCount: blkResult.IsOk ? blkResult.Value : 0,
Mode: modeResult.IsOk ? modeResult.Value : 0);
}
public async Task<FocasTimer> GetTimerAsync(FocasTimerKind kind, CancellationToken cancellationToken)
{
RequireConnected();
var result = await _wire.ReadTimerAsync((short)kind, cancellationToken).ConfigureAwait(false);
ThrowIfRcNonZero(result.Rc, $"cnc_rdtimer kind={kind}", result.IsOk);
var t = result.Value!;
return new FocasTimer(kind, t.Minutes, t.Milliseconds);
}
public async Task<IReadOnlyList<FocasServoLoad>> GetServoLoadsAsync(CancellationToken cancellationToken)
{
if (!_wire.IsConnected) return [];
var result = await _wire.ReadServoMeterAsync(32, cancellationToken).ConfigureAwait(false);
if (!result.IsOk || result.Value is null) return [];
return result.Value
.Select(m => new FocasServoLoad(m.Name ?? string.Empty, m.Value / Math.Pow(10.0, m.Decimal)))
.Where(s => s.AxisName.Length > 0)
.ToList();
}
public Task<IReadOnlyList<int>> GetSpindleLoadsAsync(CancellationToken cancellationToken) =>
ReadSpindleMetricAsync((sel, ct) => _wire.ReadSpindleLoadAsync(sel, ct), cancellationToken);
public Task<IReadOnlyList<int>> GetSpindleMaxRpmsAsync(CancellationToken cancellationToken) =>
ReadSpindleMetricAsync((sel, ct) => _wire.ReadSpindleMaxRpmAsync(sel, ct), cancellationToken);
private static async Task<IReadOnlyList<int>> ReadSpindleMetricAsync(
Func<short, CancellationToken, Task<FocasResult<IReadOnlyList<WireSpindleMetric>>>> call,
CancellationToken cancellationToken)
{
var result = await call(-1, cancellationToken).ConfigureAwait(false);
if (!result.IsOk || result.Value is null) return [];
var list = new List<int>();
foreach (var m in result.Value)
{
// Fanuc pads unused spindle slots with 0 — stop at the first trailing zero so the
// list length matches the configured spindle count.
if (m.Value == 0 && list.Count > 0) break;
list.Add(m.Value);
}
return list;
}
public void Dispose() => _wire.Dispose();
// ---- PMC / Parameter / Macro read paths ------------------------------------------
private async Task<(object? value, uint status)> ReadPmcAsync(
FocasAddress address, FocasDataType type, CancellationToken cancellationToken)
{
var area = FocasPmcAreaLookup.FromLetter(address.PmcLetter ?? string.Empty);
if (area is null) return (null, FocasStatusMapper.BadNodeIdUnknown);
var dataType = FocasPmcDataTypeLookup.FromFocasDataType(type);
var start = (ushort)address.Number;
var end = start;
try
{
var result = await _wire.ReadPmcRangeAsync(area.Value, dataType, start, end, cancellationToken)
.ConfigureAwait(false);
if (!result.IsOk || result.Value is null)
return (null, FocasStatusMapper.MapFocasReturn(result.Rc));
var values = result.Value.Values;
if (values.Count == 0) return (null, FocasStatusMapper.BadOutOfRange);
var raw = values[0];
var mapped = type switch
{
FocasDataType.Bit => (object)(((long)raw >> (address.BitIndex ?? 0) & 1L) != 0),
FocasDataType.Byte => (object)(sbyte)(raw & 0xFFL),
FocasDataType.Int16 => (object)(short)raw,
FocasDataType.Int32 => (object)(int)raw,
FocasDataType.Float32 => (object)BitConverter.Int32BitsToSingle((int)raw),
FocasDataType.Float64 => (object)BitConverter.Int64BitsToDouble(raw),
_ => (object)raw,
};
return (mapped, FocasStatusMapper.Good);
}
catch (FocasWireException ex)
{
return (null, ex.Rc is short rc ? FocasStatusMapper.MapFocasReturn(rc) : FocasStatusMapper.BadCommunicationError);
}
}
private async Task<(object? value, uint status)> ReadParameterAsync(
FocasAddress address, FocasDataType type, CancellationToken cancellationToken)
{
try
{
switch (type)
{
case FocasDataType.Byte:
var b = await _wire.ReadParameterByteAsync((short)address.Number, 0, cancellationToken).ConfigureAwait(false);
return b.IsOk ? ((object)(sbyte)b.Value, FocasStatusMapper.Good) : (null, FocasStatusMapper.MapFocasReturn(b.Rc));
case FocasDataType.Int16:
var s = await _wire.ReadParameterInt16Async((short)address.Number, 0, cancellationToken).ConfigureAwait(false);
return s.IsOk ? ((object)s.Value, FocasStatusMapper.Good) : (null, FocasStatusMapper.MapFocasReturn(s.Rc));
case FocasDataType.Float32:
var f = await _wire.ReadParameterFloat32Async((short)address.Number, 0, cancellationToken).ConfigureAwait(false);
return f.IsOk ? ((object)f.Value, FocasStatusMapper.Good) : (null, FocasStatusMapper.MapFocasReturn(f.Rc));
case FocasDataType.Float64:
var d = await _wire.ReadParameterFloat64Async((short)address.Number, 0, cancellationToken).ConfigureAwait(false);
return d.IsOk ? ((object)d.Value, FocasStatusMapper.Good) : (null, FocasStatusMapper.MapFocasReturn(d.Rc));
case FocasDataType.Bit when address.BitIndex is int bit:
var bi = await _wire.ReadParameterInt32Async((short)address.Number, 0, cancellationToken).ConfigureAwait(false);
if (!bi.IsOk) return (null, FocasStatusMapper.MapFocasReturn(bi.Rc));
return ((object)((bi.Value >> bit & 1) != 0), FocasStatusMapper.Good);
default:
var i = await _wire.ReadParameterInt32Async((short)address.Number, 0, cancellationToken).ConfigureAwait(false);
return i.IsOk ? ((object)i.Value, FocasStatusMapper.Good) : (null, FocasStatusMapper.MapFocasReturn(i.Rc));
}
}
catch (FocasWireException ex)
{
return (null, ex.Rc is short rc ? FocasStatusMapper.MapFocasReturn(rc) : FocasStatusMapper.BadCommunicationError);
}
}
private async Task<(object? value, uint status)> ReadMacroAsync(
FocasAddress address, CancellationToken cancellationToken)
{
try
{
var result = await _wire.ReadMacroAsync((short)address.Number, cancellationToken).ConfigureAwait(false);
if (!result.IsOk || result.Value is null)
return (null, FocasStatusMapper.MapFocasReturn(result.Rc));
var m = result.Value;
// Macro value is scaled-decimal: the real value is Value / 10^Decimal.
var scaled = m.Value / Math.Pow(10.0, m.Decimal);
return ((object)scaled, FocasStatusMapper.Good);
}
catch (FocasWireException ex)
{
return (null, ex.Rc is short rc ? FocasStatusMapper.MapFocasReturn(rc) : FocasStatusMapper.BadCommunicationError);
}
}
private void RequireConnected()
{
if (!_wire.IsConnected)
throw new InvalidOperationException("FOCAS wire session not connected.");
}
private static void ThrowIfRcNonZero(short rc, string call, bool isOk)
{
if (!isOk) throw new InvalidOperationException($"{call} failed EW_{rc}.");
}
}
/// <summary>Factory producing <see cref="WireFocasClient"/> instances — one per configured device.</summary>
public sealed class WireFocasClientFactory : IFocasClientFactory
{
public IFocasClient Create() => new WireFocasClient();
}

View File

@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.FOCAS</RootNamespace>
<AssemblyName>ZB.MOM.WW.OtOpcUa.Driver.FOCAS</AssemblyName>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0"/>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests"/>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests"/>
</ItemGroup>
</Project>