using Microsoft.Extensions.Logging; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire; /// /// implementation backed by the in-tree managed /// . No P/Invoke, no Fwlib64.dll, no out-of-process /// Host — the wire client dials the CNC on TCP:8193 directly and speaks the FOCAS/2 /// Ethernet binary protocol. /// /// /// OtOpcUa is read-only against FOCAS. returns /// for every address — the managed wire /// client intentionally does not expose cnc_wrparam / pmc_wrpmcrng / /// cnc_wrmacro. /// public sealed class WireFocasClient : IFocasClient { private readonly FocasWireClient _wire; private FocasHostAddress? _address; /// /// Default constructor — wire client without logger. Selected by the legacy /// no-arg path. /// public WireFocasClient() : this(logger: null) { } /// /// Construct with an optional logger. Threaded through to /// so the per-response Debug entries actually reach /// the host's logging pipeline (Driver.FOCAS-007). /// /// Optional logger for debug output from wire client responses. public WireFocasClient(ILogger? logger) { _wire = new FocasWireClient(logger); } /// Gets a value indicating whether the wire client is connected to the FOCAS host. public bool IsConnected => _wire.IsConnected; /// Connects to a FOCAS host at the specified address. /// The host address containing the machine name and port. /// The connection timeout; values less than or equal to zero are clamped to 1 second. /// Cancellation token for the operation. 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); } /// Reads a value from the specified FOCAS address. /// The FOCAS address to read from. /// The FOCAS data type of the value. /// Cancellation token for the operation. /// A tuple containing the read value and FOCAS status code. 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), }; } /// Writes a value to a FOCAS address (always returns BadNotWritable as OtOpcUa is read-only). /// The FOCAS address to write to. /// The FOCAS data type of the value. /// The value to write. /// Cancellation token for the operation. /// A task that returns the BadNotWritable status code. public Task WriteAsync( FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken) => Task.FromResult(FocasStatusMapper.BadNotWritable); /// Probes the FOCAS host to verify connectivity. /// Cancellation token for the operation. /// True if the probe succeeds; otherwise false. public async Task ProbeAsync(CancellationToken cancellationToken) { if (!_wire.IsConnected) return false; try { var result = await _wire.ReadStatusAsync(cancellationToken).ConfigureAwait(false); return result.IsOk; } catch (FocasWireException) { return false; } } /// Reads all active alarms from the FOCAS host. /// Cancellation token for the operation. /// A list of active alarms; empty if read fails or not connected. public async Task> 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); } /// Gets system information from the FOCAS host. /// Cancellation token for the operation. /// The FOCAS system information. public async Task 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); } /// Gets the names of all axes on the FOCAS host. /// Cancellation token for the operation. /// A list of axis names; empty if read fails or not connected. public async Task> 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); } } /// Gets the names of all spindles on the FOCAS host. /// Cancellation token for the operation. /// A list of spindle names; empty if read fails or not connected. public async Task> 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); } } /// Reads the dynamic state of a specified axis. /// The index of the axis to read. /// Cancellation token for the operation. /// The dynamic snapshot of the axis. public async Task 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); } /// Gets information about the currently executing program. /// Cancellation token for the operation. /// The current program information. public async Task 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); // The fixed-tree bootstrap probe (FocasDriver.SafeTryProbe) classifies the // ProgramInfo capability as "supported" iff this method returns non-null. A CNC // series without cnc_exeprgname2 / cnc_rdopmode answers EW_FUNC / EW_NOOPT, so // throw when neither the program-name nor the op-mode read succeeded — otherwise // SafeTryProbe records a false-positive capability and the driver emits Program/ // OperationMode/ subtrees that only ever return BadDeviceFailure. if (!nameResult.IsOk && !modeResult.IsOk) throw new InvalidOperationException( $"cnc_exeprgname2 failed EW_{nameResult.Rc} and cnc_rdopmode failed EW_{modeResult.Rc}."); 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); } /// Gets a timer value from the FOCAS host. /// The kind of timer to read (run time, cutting time, etc.). /// Cancellation token for the operation. /// The timer value. public async Task 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); } /// Gets servo load information for all axes. /// Cancellation token for the operation. /// A list of servo load values for each axis; empty if read fails or not connected. public async Task> 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(); } /// Gets spindle load information for all spindles. /// Cancellation token for the operation. /// A list of spindle load percentages; empty if read fails or not connected. public Task> GetSpindleLoadsAsync(CancellationToken cancellationToken) => ReadSpindleMetricAsync((sel, ct) => _wire.ReadSpindleLoadAsync(sel, ct), cancellationToken); /// Gets maximum RPM information for all spindles. /// Cancellation token for the operation. /// A list of maximum RPM values for each spindle; empty if read fails or not connected. public Task> GetSpindleMaxRpmsAsync(CancellationToken cancellationToken) => ReadSpindleMetricAsync((sel, ct) => _wire.ReadSpindleMaxRpmAsync(sel, ct), cancellationToken); private static async Task> ReadSpindleMetricAsync( Func>>> call, CancellationToken cancellationToken) { var result = await call(-1, cancellationToken).ConfigureAwait(false); if (!result.IsOk || result.Value is null) return []; var list = new List(); 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; } /// Disposes the wire client and releases all resources. 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}."); } } /// Factory producing instances — one per configured device. public sealed class WireFocasClientFactory : IFocasClientFactory { private readonly ILogger? _logger; /// Initializes a new instance of the WireFocasClientFactory without a logger. public WireFocasClientFactory() : this(logger: null) { } /// /// Construct the factory with a logger that every created /// forwards to its . Resolves Driver.FOCAS-007 — the wire /// client already emits Debug entries per FOCAS response, but the previous no-arg /// factory path discarded them. /// /// Optional logger for debug output from wire client responses. public WireFocasClientFactory(ILogger? logger) { _logger = logger; } /// /// No-op usability probe — the wire backend is always usable at config time. /// Implements . /// public void EnsureUsable() { } /// Creates a new WireFocasClient instance. /// A new IFocasClient implementation. public IFocasClient Create() => new WireFocasClient(_logger); }