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. /// /// Optional logger for debug output from wire client responses. public WireFocasClient(ILogger? logger) { _wire = new FocasWireClient(logger); } /// 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, type, cancellationToken).ConfigureAwait(false), _ => (null, FocasStatusMapper.BadNotSupported), }; } /// public Task WriteAsync( FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken) => Task.FromResult(FocasStatusMapper.BadNotWritable); /// 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; } } /// 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); } /// 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); } /// 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); } } /// 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); } } /// public async Task ReadDynamicAsync(int axisIndex, CancellationToken cancellationToken) { // FOCAS axes are 1-based; cnc_rddynamic2 with axis 0 returns EW_4 (live-confirmed on the // 31i-B). The FixedTree poll already iterates 1..AxesCount, but enforce the contract so a // future caller can't silently request axis 0. 2026-06-25. if (axisIndex < 1) throw new ArgumentOutOfRangeException(nameof(axisIndex), axisIndex, "FOCAS axis index is 1-based."); 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); // Positions keep the scale their own 0x0026 block reported; the driver converts them to // engineering units at publish (FocasPosition.ToEngineering). 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 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}."); return MapProgramInfo(nameResult, blkResult, modeResult); } /// /// Combine the three program sub-reads into a , leaving any /// field the CNC did not report null. A refused read, or an cnc_exeprgname2 payload /// too short to carry the trailing O-number, used to publish a fabricated 0 at Good /// quality — indistinguishable from a real 0 (a 31i reports O-number 0 for a program run /// by path) and, for the mode, from MDI. Ported from the sister ignitionoee driver (101a3b1). /// /// The cnc_exeprgname2 result. /// The cnc_rdblkcount result. /// The cnc_rdopmode result. /// The snapshot, null where the CNC did not say. internal static FocasProgramInfo MapProgramInfo( FocasResult name, FocasResult blockCount, FocasResult mode) { var wireName = name.IsOk ? name.Value : null; return new FocasProgramInfo( Name: wireName?.Name, ONumber: wireName?.ONumber, BlockCount: blockCount.IsOk ? blockCount.Value : null, Mode: mode.IsOk ? mode.Value : null); } /// 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); } /// 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.Scaled)) .Where(s => s.AxisName.Length > 0) .ToList(); } /// public Task> GetSpindleLoadsAsync(CancellationToken cancellationToken) => ReadSpindleMetricAsync((sel, ct) => _wire.ReadSpindleLoadAsync(sel, ct), cancellationToken); /// public Task> GetSpindleMotorSpeedsAsync(CancellationToken cancellationToken) => ReadSpindleMetricAsync((sel, ct) => _wire.ReadSpindleMotorSpeedAsync(sel, ct), cancellationToken); /// /// One cnc_rdspmeter read as per-slot percentages (null for an undescribable slot). /// Every slot is returned — the driver reads only the slots of discovered spindles. (This /// used to stop at the first zero after slot 1, which both dropped a genuinely idle second /// spindle and kept non-zero buffer residue.) /// 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 []; return result.Value.Select(m => m.Percent).ToList(); } /// 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; // pmc_rdpmcrng returns (end-start+1) BYTES. A multi-byte slot (Word/Long/Real/Double) needs // the range widened to its byte width, else the value parser gets too few bytes → 0 values // → spurious BadOutOfRange (live-confirmed on the 31i-B: a Word read with end=start returned // a single byte). Bit/Byte stay width 1 so end==start. 2026-06-25. var end = (ushort)(start + FocasWireClient.PmcByteWidth((short)dataType) - 1); 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, FocasDataType type, 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)); return MapMacro(result.Value, type, address.BitIndex); } catch (FocasWireException ex) { return (null, ex.Rc is short rc ? FocasStatusMapper.MapFocasReturn(rc) : FocasStatusMapper.BadCommunicationError); } } /// /// Turn a decoded macro into the value + quality its tag publishes. /// /// /// A vacant variable has no value at all → , /// never a Good 0 (its mantissa is 0, indistinguishable from a genuine zero). /// Float tags get mantissa / 10^decimal. Integer tags (Byte / Int16 / /// Int32 / Bit) get the exact whole value, tested with integer arithmetic /// (mantissa % 10^decimal) so no epsilon is involved; a fractional macro on an /// integer tag is — truncating or rounding /// would publish a plausible wrong count at Good quality — and a whole value that does not /// fit the type is . Ported from the sister /// ignitionoee driver's macroInteger/macroReal (Focas-017/018). /// /// The decoded macro. /// The tag's declared data type. /// Optional bit index for a tag. /// The boxed value (null when Bad) and its status. internal static (object? value, uint status) MapMacro(WireMacro m, FocasDataType type, int? bitIndex) { if (m.Scaled is not { } scaled) return (null, FocasStatusMapper.BadNoData); switch (type) { case FocasDataType.Float64: return (scaled, FocasStatusMapper.Good); case FocasDataType.Float32: return ((float)scaled, FocasStatusMapper.Good); case FocasDataType.Byte or FocasDataType.Int16 or FocasDataType.Int32 or FocasDataType.Bit: if (!TryWholeMacroValue(m, out var whole)) return (null, FocasStatusMapper.BadTypeMismatch); return type switch { FocasDataType.Bit => (bitIndex is int bit ? ((whole >> bit) & 1L) != 0 : whole != 0, FocasStatusMapper.Good), FocasDataType.Byte when whole is >= sbyte.MinValue and <= sbyte.MaxValue => ((sbyte)whole, FocasStatusMapper.Good), FocasDataType.Int16 when whole is >= short.MinValue and <= short.MaxValue => ((short)whole, FocasStatusMapper.Good), FocasDataType.Int32 when whole is >= int.MinValue and <= int.MaxValue => ((int)whole, FocasStatusMapper.Good), _ => (null, FocasStatusMapper.BadOutOfRange), }; default: return (scaled, FocasStatusMapper.Good); } } /// Exact mantissa / 10^decimal when it is a whole number; integer arithmetic only. private static bool TryWholeMacroValue(WireMacro m, out long whole) { whole = 0; if (m.Value == 0) return true; // 0 is whole at any decimal if (m.Decimal > 18) return false; // an i32 mantissa over 10^19+ is always fractional var divisor = 1L; for (var i = 0; i < m.Decimal; i++) divisor *= 10L; if (m.Value % divisor != 0) return false; whole = m.Value / divisor; return true; } 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 — 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 — the wire backend is always usable at config time. public void EnsureUsable() { } /// public IFocasClient Create() => new WireFocasClient(_logger); }