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.
/// A task that represents the asynchronous 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.
/// A task that represents the asynchronous 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.
///
/// A task that represents the asynchronous operation.
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 .
/// The CNC identity/sysinfo, wrapped in a .
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 .
/// The aggregated CNC status bits, wrapped in a .
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 .
/// The configured axis name records, wrapped in a .
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 .
/// The configured spindle name records, wrapped in a .
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 .
/// The fast-poll dynamic snapshot for the axis, wrapped in a .
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);
return new FocasResult(rc, ParseDynamic2(blocks));
}
///
/// Aggregate the cnc_rddynamic2 response blocks of one axis read into a
/// . The four 0x0026 blocks are taken in request order
/// (absolute / machine / relative / distance-to-go), each decoded with its own scale by
/// .
///
/// The response blocks of one request.
/// The decoded fast-poll bundle.
internal static WireDynamic ParseDynamic2(IReadOnlyList blocks)
{
var programPayload = FindPayload(blocks, 0x001c);
return 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)));
}
///
/// Decode one cnc_rddynamic2 position block (0x0026). The 8-byte block is
/// self-describing — [0..3] i32 raw, [4..5] u16 base, [6..7] u16 exponent,
/// value = raw / base^exponent — pinned on a real FANUC 31i-B (every block of
/// ref-dynamic2-axis1.bin reports base 10 / exponent 4). A 4..7-byte block carries no
/// envelope and is returned scale-less (the driver then applies the configured
/// PositionDecimalPlaces); a shorter one decodes as a scale-less 0, as before.
///
///
/// Validity (vacant 0xFFFF exponent, a base other than 2/10, an absurd exponent) is a
/// per-node quality decision made by , not a fault here:
/// raising would let one empty slot take out the whole poll. Ported from the sister
/// ignitionoee driver v0.17.0 (Dynamic2.scaledPosition, Focas.Wire-014).
///
/// The 0x0026 response-block payload.
/// The position with whatever scale the block carried.
internal static FocasPosition ParsePositionBlock(byte[] payload)
{
if (payload.Length >= 8)
return new FocasPosition(
ReadInt32(payload, 0),
BinaryPrimitives.ReadUInt16BigEndian(payload.AsSpan(4, 2)),
BinaryPrimitives.ReadUInt16BigEndian(payload.AsSpan(6, 2)),
HasScale: true);
return new FocasPosition(payload.Length >= 4 ? ReadInt32(payload, 0) : 0);
}
/// 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 .
/// The servo-meter load percentages, wrapped in a .
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 result = ParseServoMeters(FindPayload(blocks, 0x0056), FindPayload(blocks, 0x0089), maxCount);
return new FocasResult>(rc, result);
}
///
/// Read the per-spindle load meter via cnc_rdspmeter (command 0x0040, type 0).
/// is % of rated load (16384 = 100%).
///
/// Spindle selector; -1 selects all spindles.
/// Cancellation token for the read operation.
/// Optional per-call timeout override.
/// Optional path ID override; defaults to .
/// The per-spindle load meters, wrapped in a .
public Task>> ReadSpindleLoadAsync(
short spindleSelector = -1,
CancellationToken cancellationToken = default,
TimeSpan? timeout = null,
ushort? pathId = null)
=> ReadSpindleMetricAsync(0, spindleSelector, cancellationToken, timeout, pathId);
///
/// Read the per-spindle motor-speed meter via cnc_rdspmeter (command 0x0040,
/// type 1 — historically labelled cnc_rdspmaxrpm). It is NOT a maximum-RPM constant:
/// it is the live motor speed as a fraction of maximum,
/// (a regression against cnc_rddynamic2 spindle speed on a real 31i-B gives
/// rpm = 1.220749 × meter, i.e. 16384 = the spindle's 20,000 rpm maximum).
///
/// Spindle selector; -1 selects all spindles.
/// Cancellation token for the read operation.
/// Optional per-call timeout override.
/// Optional path ID override; defaults to .
/// The per-spindle motor-speed meters, wrapped in a .
public Task>> ReadSpindleMotorSpeedAsync(
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 .
/// The raw parameter payload bytes, wrapped in a .
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 .
/// The typed parameter value, wrapped in a .
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 .
/// The typed 8-bit parameter value, wrapped in a .
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 .
/// The typed 16-bit parameter value, wrapped in a .
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 .
/// The typed 32-bit parameter value, wrapped in a .
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 .
/// The typed single-precision parameter value, wrapped in a .
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 .
/// The typed double-precision parameter value, wrapped in a .
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 .
/// The macro variable value, wrapped in a .
public Task> ReadMacroAsync(
short number,
CancellationToken cancellationToken = default,
TimeSpan? timeout = null,
ushort? pathId = null)
=> ReadSingleWithTimeoutAsync(
0x0015,
payload => ParseMacro(number, payload),
cancellationToken, timeout, EffectivePathId(pathId), number, number);
///
/// Decode a cnc_rdmacro (0x0015) payload. The on-wire ODBM record is 8
/// big-endian bytes: [0..3] i32 mantissa, [4..5] i16 length field (a
/// constant 10 on every observed response), [6..7] i16 dec_val
/// (0xFFFF = vacant).
///
///
/// The middle field is the trap: it is not the decimal count but looks like a plausible one,
/// so reading the decimal at offset 4 decoded every macro on every control without ever
/// failing — just wrongly (the captured #500 = 1.0 published 0.01). Pinned by
/// a live Makino 31i-B capture; found and fixed by the sister ignitionoee driver
/// (Focas.Wire-013, 2026-08-05). A payload shorter than 8 bytes carries no
/// dec_val and is refused rather than guessed at.
///
/// The macro number the request asked for.
/// The response-block payload.
/// The decoded macro.
/// The payload is shorter than 8 bytes.
internal static WireMacro ParseMacro(short number, byte[] payload)
{
RequireLength(payload, 8, "cnc_rdmacro");
return new WireMacro(number, ReadInt32(payload, 0), ReadInt16(payload, 6));
}
///
/// 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 .
/// The decoded PMC range values, wrapped in a .
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 => ParsePmcRange(area, dataType, start, end, payload));
}
/// 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 .
/// The decoded PMC range values, wrapped in a .
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 .
/// The active alarm list, wrapped in a .
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 .
/// The current operation mode, wrapped in a .
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 .
/// The raw operation mode code, wrapped in a .
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 .
/// The executing program name and O-number, wrapped in a .
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 .
/// The executed block count, wrapped in a .
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 .
/// The cumulative timer value, wrapped in a .
public Task> ReadTimerAsync(
short type,
CancellationToken cancellationToken = default,
TimeSpan? timeout = null,
ushort? pathId = null)
=> ReadSingleWithTimeoutAsync(
0x0120,
payload => ParseTimer(type, payload),
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, ParseSpindleMetrics);
}
///
/// Decode a cnc_rdspmeter (0x0040) payload: 8-byte records of
/// [+0..3] i32 value, [+4..5] u16 base, [+6..7] u16 exponent, one per
/// slot of a fixed multi-slot buffer (slots past the spindle count are residue and decode
/// as = false). Every slot is returned, in wire order.
///
/// The response-block payload.
/// The per-slot meters.
internal static IReadOnlyList ParseSpindleMetrics(byte[] 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),
BinaryPrimitives.ReadUInt16BigEndian(payload.AsSpan(offset + 4, 2)),
BinaryPrimitives.ReadUInt16BigEndian(payload.AsSpan(offset + 6, 2))));
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;
///
/// Decode a cnc_rdtimer (0x0120) payload into . The 8-byte
/// data block is two 32-bit fields {minute, msec}, and they are little-endian on the
/// wire — unlike the big-endian block envelope and every other payload (sysinfo / dynamic /
/// macro). Validated against a live FANUC 31i-B (2026-06-25): the msec field only falls in
/// valid range (0..59999) under little-endian; big-endian decoded it as ~2.4e9. Captured
/// cutting-time payload ac f2 10 00 90 a3 00 00 → minute=1110188, msec=41872. See
/// docs/plans/2026-06-25-focas-pdu-v3-30i-b-support.md.
///
/// Timer type selector, carried through into the returned .
/// The raw 8-byte cnc_rdtimer response payload.
/// The decoded minute/msec timer value.
internal static WireTimer ParseTimer(short type, byte[] payload) => new(
type,
payload.Length >= 4 ? BinaryPrimitives.ReadInt32LittleEndian(payload.AsSpan(0, 4)) : 0,
payload.Length >= 8 ? BinaryPrimitives.ReadInt32LittleEndian(payload.AsSpan(4, 4)) : 0);
///
/// Decode a cnc_rdsvmeter response into records. Each
/// per-axis record is 8 bytes — [+0..3] i32 value, [+4..5] u16 base,
/// [+6..7] u16 exponent — the same self-describing envelope as the position blocks,
/// so the load is value / base^exponent, a direct percentage. (A 12-byte stride
/// misaligned after the first record; reading offset 4 as a decimal count divided every load
/// by 10^10 — both fixed against live 31i-B captures, the latter by the sister ignitionoee
/// driver, Focas.Wire-019.) Axis NAMES are not in the svmeter payload; they come from the
/// cnc_rdaxisname (0x0089) block requested alongside it and are correlated by index.
/// The payload is a fixed 32-slot buffer: slots past the controlled-axis count are residue
/// and decode as = false.
///
/// The raw cnc_rdsvmeter response payload.
/// The raw cnc_rdaxisname response payload used to correlate axis names by index.
/// Maximum number of servo meter records to return.
/// The decoded servo-meter records.
internal static IReadOnlyList ParseServoMeters(byte[] svPayload, byte[] axisNamePayload, int maxCount)
{
var result = new List();
for (var offset = 0; offset + 8 <= svPayload.Length && result.Count < maxCount; offset += 8)
{
var index = result.Count;
var nameOffset = index * 4;
var name = nameOffset + 4 <= axisNamePayload.Length
? FocasWireProtocol.ReadNameRecord(axisNamePayload.AsSpan(nameOffset, 4))
: string.Empty;
result.Add(new WireServoMeter(
(short)(index + 1),
name,
ReadInt32(svPayload, offset),
BinaryPrimitives.ReadUInt16BigEndian(svPayload.AsSpan(offset + 4, 2)), // BASE, not a decimal
BinaryPrimitives.ReadUInt16BigEndian(svPayload.AsSpan(offset + 6, 2))));
}
return result;
}
/// Byte width of one PMC slot for a FOCAS data-type code: Byte=1, Word=2, Long/Real=4, Double=8.
/// The PMC data width code.
/// The byte width of one slot for the given data-type code.
internal static int PmcByteWidth(short dataType) => dataType switch
{
1 => 2,
2 or 4 => 4,
5 => 8,
_ => 1,
};
///
/// Decode a pmc_rdpmcrng payload into . The CNC returns
/// (end-start+1) BYTES; this slices them into width-sized big-endian slots. Callers must
/// size the request range to width bytes per value (see
/// WireFocasClient.ReadPmcAsync) or a trailing partial slot is dropped — which on the
/// 31i-B surfaced as a spurious BadOutOfRange for a single Word read. 2026-06-25.
///
/// The PMC address-letter code numeric value.
/// The PMC data width code numeric value.
/// The starting address.
/// The ending address.
/// The raw pmc_rdpmcrng response payload.
/// The decoded PMC range values.
internal static WirePmcRange ParsePmcRange(short area, short dataType, ushort start, ushort end, byte[] payload)
{
var width = PmcByteWidth(dataType);
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);
}
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 FocasPosition ReadSelectorPosition(IReadOnlyList blocks, ushort command, int selectorIndex)
{
var seen = 0;
foreach (var block in blocks)
{
if (block.Command != command) continue;
if (seen == selectorIndex)
return ParsePositionBlock(block.Payload);
seen++;
}
return new FocasPosition(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);
}
///
/// Decode a cnc_rdalmmsg2 payload: the 76-byte vendor stride, else the 80-byte legacy one. An empty
/// payload is "no alarms active". A non-empty payload that fits NEITHER stride is malformed and throws
/// (Gitea #647 review): it used to fall into the legacy parser, decode zero records, and so read as "no
/// alarms" — a mass Clear of every condition on the device, then an un-acknowledged re-raise next poll.
///
/// The reply payload.
/// The requested record count.
/// The decoded alarms.
/// The payload length matches neither record layout.
internal static IReadOnlyList ParseAlarms(byte[] payload, short count)
{
if (payload.Length % 76 == 0) return ParseVendorAlarms(payload, count);
if (payload.Length % 80 == 0) return ParseLegacyWireAlarms(payload, count);
throw new FocasWireException(
$"cnc_rdalmmsg2 returned a {payload.Length}-byte payload, which fits neither the 76- nor the 80-byte " +
"record layout; refusing to read it as an empty alarm list.", rc: null);
}
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;
}
///
/// Decode an axis-name (0x0089) or spindle-name (0x008A) payload: an array of 4-byte records,
/// each named by . Blank records are skipped but
/// keep their slot's 1-based index.
///
/// The record type the factory builds.
/// The response-block payload.
/// Maximum number of records to inspect.
/// Builds one record from its 1-based index and name.
/// The non-blank records, in wire order.
internal 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));
}