using System.Buffers.Binary; using System.Net.Sockets; using Microsoft.Extensions.Logging; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire; /// /// Pure-managed read-only FOCAS/2 Ethernet wire client. Speaks the proprietary Fanuc /// binary protocol on TCP:8193 directly — no P/Invoke, no Fwlib64.dll, no /// out-of-process Host. One instance owns two TCP sockets for the duration of a CNC /// session; runs the /// two-socket initiate handshake and a setup request, subsequent reads reuse /// socket 2 serialised through an internal semaphore. /// /// /// Read surface. Covers every FOCAS call OtOpcUa's managed driver issues: /// sysinfo, status, axis + spindle names, the cnc_rddynamic2 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. /// Concurrency. Callers may issue reads concurrently from multiple threads /// — socket 2 is guarded by a so at most one /// request/response pair is in flight at a time. /// and share a second semaphore to stop the two racing. /// Transient failures. When cancellation or a socket-level error happens /// mid-request the client closes both sockets and throws /// with /// 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. /// public sealed class FocasWireClient : IAsyncDisposable, IDisposable { private readonly ILogger? _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? _sysInfo; /// /// Construct a disconnected client. Optional receives /// Debug-level entries per response block (command ID, RC, payload length). /// /// Optional logger for debug-level wire protocol entries. public FocasWireClient(ILogger? logger = null) { _logger = logger; } /// /// Default PathId applied when no per-call override is supplied. Relevant for /// multi-path CNCs; single-path controllers leave this at the default of 1. /// public ushort PathId { get; set; } = 1; /// True when the two-socket handshake has completed and the transport is live. public bool IsConnected => _connected; /// /// 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 /// overload. /// /// The CNC hostname or IP address. /// The FOCAS/2 TCP port (typically 8193). /// Connection timeout in seconds; zero or negative disables the timeout. /// Cancellation token for the connect operation. public Task ConnectAsync( string host, int port, int timeoutSeconds = 10, CancellationToken cancellationToken = default) => ConnectCoreAsync( host, port, timeoutSeconds > 0 ? TimeSpan.FromSeconds(timeoutSeconds) : null, cancellationToken); /// /// Open the FOCAS session with a timeout. Pass /// to disable the timeout entirely (rely on the caller's /// instead). Idempotent. /// /// The CNC hostname or IP address. /// The FOCAS/2 TCP port (typically 8193). /// Connection timeout duration; disables the timeout. /// Cancellation token for the connect operation. 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(); } } /// /// Synchronous dispose — sends the close PDU when connected and tears down both /// sockets. Idempotent. Callers on an async context should prefer /// . /// public void Dispose() { _lifetimeGate.Wait(); try { if (_disposed) return; _disposed = true; if (_stream2 is not null && _connected) { try { SendPdu(_stream2, FocasWireProtocol.TypeClose, ReadOnlySpan.Empty); _ = FocasWireProtocol.ReadPdu(_stream2); } catch { // Close best-effort — don't let teardown failure hide a caller's real error. } } CloseTransport(); } finally { _lifetimeGate.Release(); } } /// /// Async dispose — sends the close PDU when connected and tears down both sockets. /// Idempotent. /// 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.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(); } } /// /// Read CNC identity via cnc_sysinfo. Cached from the connect-time exchange /// unless a per-call override is supplied. /// /// Cancellation token for the read operation. /// Optional per-call timeout override. /// Optional path ID override; defaults to . public async Task> 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); } /// Read CNC status bits via cnc_statinfo (3 command blocks aggregated into one ). /// Cancellation token for the read operation. /// Optional per-call timeout override. /// Optional path ID override; defaults to . public async Task> 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(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( 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)); } /// Read configured axis names via cnc_rdaxisname (command 0x0089). /// Maximum number of axis records to return. /// Cancellation token for the read operation. /// Optional per-call timeout override. /// Optional path ID override; defaults to . public async Task>> 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))); } /// Read configured spindle names via cnc_rdspdlname (command 0x008a). /// Maximum number of spindle records to return. /// Cancellation token for the read operation. /// Optional per-call timeout override. /// Optional path ID override; defaults to . public async Task>> 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))); } /// /// Fast-poll bundle for one axis via cnc_rddynamic2. 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. /// /// The axis number to read from. /// Cancellation token for the read operation. /// Optional per-call timeout override. /// Optional path ID override; defaults to . public async Task> 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(rc, null); var programPayload = FindPayload(blocks, 0x001c); return new FocasResult( 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)))); } /// Read servo-meter load percentages via cnc_rdsvmeter (command 0x0056). /// Maximum number of servo meter records to return. /// Cancellation token for the read operation. /// Optional per-call timeout override. /// Optional path ID override; defaults to . public async Task>> 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>(rc, null); var payload = FindPayload(blocks, 0x0056); var result = new List(); 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>(rc, result); } /// /// Read the per-axis position decimal-place figures via cnc_getfigure /// (command 0x00d3). The response payload is a self-delimiting sequence of /// big-endian short decimal-place counts — one per configured axis — which the /// driver applies as a 10^figure scale on raw position reads. /// /// /// The 0x00d3 command id is co-designed with the in-tree focas_mock sim and /// validated against it; like the rest of this managed wire backend the binary shape is /// sim-consistent and has not been validated against a real Fanuc CNC (bench-CNC-gated). /// /// Cancellation token for the read operation. /// Optional per-call timeout override. /// Optional path ID override; defaults to . public async Task>> ReadPositionFiguresAsync( CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null) { using var callTimeout = CreateCallTimeout(cancellationToken, timeout); var requestPathId = EffectivePathId(pathId); var block = await SendSingleRequestAsync( callTimeout.Token, new RequestBlock(0x00d3, PathId: requestPathId)).ConfigureAwait(false); if (block.Rc != 0) return new FocasResult>(block.Rc, null); var payload = block.Payload; var figures = new List(payload.Length / 2); for (var offset = 0; offset + 2 <= payload.Length; offset += 2) figures.Add(ReadInt16(payload, offset)); return new FocasResult>(block.Rc, figures); } /// Read per-spindle load percentages via cnc_rdspload (command 0x0040 with arg1=0). /// Spindle selector; -1 selects all spindles. /// Cancellation token for the read operation. /// Optional per-call timeout override. /// Optional path ID override; defaults to . public Task>> ReadSpindleLoadAsync( short spindleSelector = -1, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null) => ReadSpindleMetricAsync(0, spindleSelector, cancellationToken, timeout, pathId); /// Read per-spindle maximum RPMs via cnc_rdspmaxrpm (command 0x0040 with arg1=1). /// Spindle selector; -1 selects all spindles. /// Cancellation token for the read operation. /// Optional per-call timeout override. /// Optional path ID override; defaults to . public Task>> ReadSpindleMaxRpmAsync( short spindleSelector = -1, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null) => ReadSpindleMetricAsync(1, spindleSelector, cancellationToken, timeout, pathId); /// /// Raw-bytes parameter read via cnc_rdparam. Caller marshals the returned /// payload to the type declared in the per-series parameter catalog. /// selects an axis-scoped parameter; 0 means global. /// /// The parameter data number. /// Axis selector; zero means a global parameter. /// Cancellation token for the read operation. /// Optional per-call timeout override. /// Optional path ID override; defaults to . public async Task> 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); } /// Typed Int32 parameter read — convenience over . /// The parameter data number. /// The FOCAS parameter type code. /// Cancellation token for the read operation. /// Optional per-call timeout override. /// Optional path ID override; defaults to . public async Task> 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(result.Rc, null); return new FocasResult( result.Rc, new WireParameter(dataNumber, type, result.Value.Length >= 4 ? ReadInt32(result.Value, 0) : 0)); } /// Typed 8-bit parameter read. /// The parameter data number. /// Axis selector; zero means a global parameter. /// Cancellation token for the read operation. /// Optional per-call timeout override. /// Optional path ID override; defaults to . public async Task> 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(result.Rc, default) : new FocasResult(result.Rc, result.Value.Length >= 1 ? result.Value[0] : default); } /// Typed 16-bit parameter read. /// The parameter data number. /// Axis selector; zero means a global parameter. /// Cancellation token for the read operation. /// Optional per-call timeout override. /// Optional path ID override; defaults to . public async Task> 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(result.Rc, default) : new FocasResult(result.Rc, result.Value.Length >= 2 ? ReadInt16(result.Value, 0) : default); } /// Typed 32-bit parameter read. /// The parameter data number. /// Axis selector; zero means a global parameter. /// Cancellation token for the read operation. /// Optional per-call timeout override. /// Optional path ID override; defaults to . public async Task> 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(result.Rc, default) : new FocasResult(result.Rc, result.Value.Length >= 4 ? ReadInt32(result.Value, 0) : default); } /// Typed IEEE-754 single-precision parameter read. /// The parameter data number. /// Axis selector; zero means a global parameter. /// Cancellation token for the read operation. /// Optional per-call timeout override. /// Optional path ID override; defaults to . public async Task> 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(result.Rc, default) : new FocasResult(result.Rc, BitConverter.Int32BitsToSingle(ReadInt32(result.Value, 0))); } /// Typed IEEE-754 double-precision parameter read. /// The parameter data number. /// Axis selector; zero means a global parameter. /// Cancellation token for the read operation. /// Optional per-call timeout override. /// Optional path ID override; defaults to . public async Task> 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(result.Rc, default) : new FocasResult(result.Rc, BitConverter.Int64BitsToDouble(BinaryPrimitives.ReadInt64BigEndian(result.Value.AsSpan(0, 8)))); } /// Read a single macro variable via cnc_rdmacro (command 0x0015). /// The macro variable number. /// Cancellation token for the read operation. /// Optional per-call timeout override. /// Optional path ID override; defaults to . public Task> 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); /// /// Read a PMC range via pmc_rdpmcrng. is the numeric /// address-letter code (see ); /// is the width code (see ). Payload is decoded into /// — one entry per slot of the requested width. /// /// The PMC address-letter code numeric value. /// The PMC data width code numeric value. /// The starting address. /// The ending address; must be greater than or equal to start. /// Cancellation token for the read operation. /// Optional per-call timeout override. /// Optional path ID override; defaults to . public async Task> 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(); 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); }); } /// Typed overload for . /// The PMC address-letter code. /// The PMC data width code. /// The starting address. /// The ending address; must be greater than or equal to start. /// Cancellation token for the read operation. /// Optional per-call timeout override. /// Optional path ID override; defaults to . public Task> 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); /// /// Read active alarms via cnc_rdalmmsg2 (command 0x0023). Parses both /// the 76-byte vendor ODBALMMSG2_data layout and the 80-byte legacy wire /// shape so the same managed surface works across firmware revisions. /// /// Alarm type filter; -1 reads all active alarms. /// Maximum number of alarms to return. /// Cancellation token for the read operation. /// Optional per-call timeout override. /// Optional path ID override; defaults to . public async Task>> 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)); } /// Read operation mode via cnc_rdopmode, returned as the typed . /// Cancellation token for the read operation. /// Optional per-call timeout override. /// Optional path ID override; defaults to . public Task> 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)); /// /// Raw-code variant of — returns the underlying /// FOCAS short so callers storing the raw mode code (e.g. OtOpcUa's /// FocasProgramInfo.Mode int field) don't have to cast the enum. /// /// Cancellation token for the read operation. /// Optional per-call timeout override. /// Optional path ID override; defaults to . public Task> 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)); /// Read the currently-executing program name + O-number via cnc_exeprgname2 (command 0x00fc). /// Cancellation token for the read operation. /// Optional per-call timeout override. /// Optional path ID override; defaults to . public Task> ReadExecutingProgramNameAsync( CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null) => ReadSingleWithTimeoutAsync(0x00fc, ParseProgramName, cancellationToken, timeout, EffectivePathId(pathId)); /// Read the executed block count via cnc_rdblkcount. /// Cancellation token for the read operation. /// Optional per-call timeout override. /// Optional path ID override; defaults to . public Task> ReadBlockCountAsync( CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null) => ReadSingleWithTimeoutAsync( 0x0035, payload => payload.Length >= 4 ? ReadInt32(payload, 0) : 0, cancellationToken, timeout, EffectivePathId(pathId)); /// /// Read one cumulative timer via cnc_rdtimer. selects /// PowerOn / Operating / Cutting / Cycle per the FOCAS spec (0..3). /// /// Timer type selector (0=PowerOn, 1=Operating, 2=Cutting, 3=Cycle). /// Cancellation token for the read operation. /// Optional per-call timeout override. /// Optional path ID override; defaults to . public Task> 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>> 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>(block, payload => { var values = new List(); 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> ReadSingleAsync( ushort command, Func 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> ReadSingleWithTimeoutAsync( ushort command, Func 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 SendSingleRequestAsync(CancellationToken cancellationToken, RequestBlock block) { var blocks = await SendRequestAsync(cancellationToken, block).ConfigureAwait(false); return blocks.Count == 0 ? new ResponseBlock(block.Command, 0, Array.Empty()) : blocks[0]; } private async Task> 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 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 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 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(); using var registration = cancellationToken.Register(static state => ((TaskCompletionSource)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 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 ToResult(ResponseBlock block, Func parser) => block.Rc != 0 ? new FocasResult(block.Rc, default) : new FocasResult(block.Rc, parser(block.Payload)); private static short AggregateRc(IReadOnlyList blocks) => blocks.FirstOrDefault(block => block.Rc != 0)?.Rc ?? 0; private static byte[] FindPayload(IReadOnlyList blocks, ushort command) => blocks.FirstOrDefault(block => block.Command == command)?.Payload ?? Array.Empty(); private static int ReadFirstInt32(IReadOnlyList blocks, ushort command) { var payload = FindPayload(blocks, command); return payload.Length >= 4 ? ReadInt32(payload, 0) : 0; } private static int ReadSelectorPosition(IReadOnlyList 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 ParseAlarms(byte[] payload, short count) => payload.Length % 76 == 0 ? ParseVendorAlarms(payload, count) : ParseLegacyWireAlarms(payload, count); private static IReadOnlyList ParseVendorAlarms(byte[] payload, short count) { var alarms = new List(); 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 ParseLegacyWireAlarms(byte[] payload, short count) { var alarms = new List(); 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 ReadNameRecords(byte[] payload, short maxCount, Func factory) { var names = new List(); 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)); }