using System.Buffers.Binary;
using System.Net.Sockets;
using System.Text;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
///
/// Framing primitives for the FOCAS/2 Ethernet wire protocol — magic-prefixed PDU
/// header + request/response block envelopes. Read-only subset: every call OtOpcUa
/// issues maps to one of the command IDs documented in
/// docs/v2/implementation/focas-wire-protocol.md.
///
///
/// All multi-byte integer fields are big-endian on the wire. The 10-byte header is
/// a0 a0 a0 a0 magic + 2-byte version + type byte + direction byte + 2-byte body
/// length. Version 1 is the only version this implementation supports.
/// Type 0x01 is the initiate handshake, 0x02 is the session close,
/// 0x21 is a request/response data PDU carrying one or more request blocks.
///
internal static class FocasWireProtocol
{
/// The PDU version this client emits in every outgoing request header.
public const ushort Version = 1;
///
/// PDU versions accepted on inbound PDUs. The 10-byte header framing is identical across
/// these (only the version field differs), so the framing layer accepts both while we keep
/// emitting (v1) on requests. The docker mock + older controls answer
/// v1; modern controls answer v3 — FANUC 30i-B validated live 2026-06-25 (macro reads OK).
/// See docs/plans/2026-06-25-focas-pdu-v3-30i-b-support.md.
///
private static readonly ushort[] SupportedReadVersions = [1, 3];
/// True when is a PDU version this client can frame-parse.
internal static bool IsSupportedReadVersion(ushort version) =>
Array.IndexOf(SupportedReadVersions, version) >= 0;
public const byte DirectionRequest = 0x01;
public const byte DirectionResponse = 0x02;
public const byte TypeInitiate = 0x01;
public const byte TypeClose = 0x02;
public const byte TypeData = 0x21;
private static readonly byte[] Magic = [0xa0, 0xa0, 0xa0, 0xa0];
/// Assemble a full PDU (10-byte header + body) for transmission.
/// The PDU type byte.
/// The direction byte (request or response).
/// The PDU body bytes.
/// The complete PDU bytes including header and body.
public static byte[] BuildPdu(byte type, byte direction, ReadOnlySpan body)
{
if (body.Length > ushort.MaxValue)
throw new ArgumentOutOfRangeException(nameof(body), "FOCAS PDU body is limited to 65535 bytes.");
var bytes = new byte[10 + body.Length];
Magic.CopyTo(bytes, 0);
BinaryPrimitives.WriteUInt16BigEndian(bytes.AsSpan(4, 2), Version);
bytes[6] = type;
bytes[7] = direction;
BinaryPrimitives.WriteUInt16BigEndian(bytes.AsSpan(8, 2), (ushort)body.Length);
body.CopyTo(bytes.AsSpan(10));
return bytes;
}
///
/// Initiate-body shape — just the 2-byte socket index (1 or 2). cnc_allclibhndl3
/// opens two TCP sockets in sequence and each sends its own initiate PDU carrying its
/// index.
///
/// The socket index (1 or 2).
/// The initiate body bytes.
public static byte[] BuildInitiateBody(ushort socketIndex)
{
var body = new byte[2];
BinaryPrimitives.WriteUInt16BigEndian(body, socketIndex);
return body;
}
/// Assemble a type-0x21 body carrying one or more request blocks.
/// The request blocks to assemble.
/// The assembled body bytes.
public static byte[] BuildRequestBody(IReadOnlyList blocks)
{
if (blocks.Count > ushort.MaxValue)
throw new ArgumentOutOfRangeException(nameof(blocks), "Too many request blocks.");
var blockBytes = blocks.Select(BuildRequestBlock).ToArray();
var bodyLength = 2 + blockBytes.Sum(block => block.Length);
if (bodyLength > ushort.MaxValue)
throw new ArgumentOutOfRangeException(nameof(blocks), "FOCAS request body is too large.");
var body = new byte[bodyLength];
BinaryPrimitives.WriteUInt16BigEndian(body.AsSpan(0, 2), (ushort)blocks.Count);
var offset = 2;
foreach (var block in blockBytes)
{
block.CopyTo(body.AsSpan(offset));
offset += block.Length;
}
return body;
}
/// Async read of one full PDU off a stream. Throws on invalid magic / version / truncation.
/// The network stream to read from.
/// Cancellation token.
/// The read PDU.
public static async Task ReadPduAsync(NetworkStream stream, CancellationToken cancellationToken)
{
var header = new byte[10];
await ReadExactlyAsync(stream, header, cancellationToken).ConfigureAwait(false);
if (!header.AsSpan(0, 4).SequenceEqual(Magic))
throw new FocasWireException("Invalid FOCAS PDU magic.");
var version = BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(4, 2));
if (!IsSupportedReadVersion(version))
throw new FocasWireException($"Unsupported FOCAS PDU version {version}.");
var bodyLength = BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(8, 2));
var body = new byte[bodyLength];
if (bodyLength > 0)
await ReadExactlyAsync(stream, body, cancellationToken).ConfigureAwait(false);
return new Pdu(header[6], header[7], body);
}
/// Synchronous counterpart to — used by 's sync dispose.
/// The network stream to read from.
/// The read PDU.
public static Pdu ReadPdu(NetworkStream stream)
{
var header = new byte[10];
ReadExactly(stream, header);
if (!header.AsSpan(0, 4).SequenceEqual(Magic))
throw new FocasWireException("Invalid FOCAS PDU magic.");
var version = BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(4, 2));
if (!IsSupportedReadVersion(version))
throw new FocasWireException($"Unsupported FOCAS PDU version {version}.");
var bodyLength = BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(8, 2));
var body = new byte[bodyLength];
if (bodyLength > 0)
ReadExactly(stream, body);
return new Pdu(header[6], header[7], body);
}
private static async Task ReadExactlyAsync(NetworkStream stream, byte[] buffer, CancellationToken cancellationToken)
{
// NetworkStream.ReadAsync's CancellationToken does not reliably abort a socket read that is
// blocked waiting for bytes the peer never sends — a CNC that TCP-accepts then stalls
// mid-PDU (the cnc_rdsvmeter "hang" the 31i-B work chased). Register a hard abort that
// disposes the stream on cancellation so a stalled read throws instead of wedging the
// caller's poll loop, and normalize the resulting failure to OperationCanceledException so
// the request path tears the transport down as a transient. See
// docs/plans/2026-06-25-focas-pdu-v3-30i-b-support.md (Phase 2).
await using var abort = cancellationToken.Register(static s => ((IDisposable)s!).Dispose(), stream);
var offset = 0;
try
{
while (offset < buffer.Length)
{
var read = await stream.ReadAsync(buffer.AsMemory(offset, buffer.Length - offset), cancellationToken).ConfigureAwait(false);
if (read == 0)
throw new EndOfStreamException("FOCAS socket closed before the expected number of bytes were read.");
offset += read;
}
}
catch (Exception ex) when (cancellationToken.IsCancellationRequested && ex is not OperationCanceledException)
{
// The stalled read was aborted by the dispose-on-cancel registration above.
throw new OperationCanceledException(cancellationToken);
}
}
private static void ReadExactly(NetworkStream stream, byte[] buffer)
{
var offset = 0;
while (offset < buffer.Length)
{
var read = stream.Read(buffer, offset, buffer.Length - offset);
if (read == 0)
throw new EndOfStreamException("FOCAS socket closed before the expected number of bytes were read.");
offset += read;
}
}
///
/// Unpack a type-0x21 response body into its constituent response blocks. Each
/// block carries the command ID, the FOCAS EW_* return code, and the payload
/// bytes.
///
/// The response body bytes.
/// The parsed response blocks.
public static IReadOnlyList ParseResponseBlocks(ReadOnlySpan body)
{
if (body.Length < 2)
return Array.Empty();
var count = BinaryPrimitives.ReadUInt16BigEndian(body.Slice(0, 2));
var blocks = new List(count);
var offset = 2;
for (var index = 0; index < count; index++)
{
if (offset + 2 > body.Length)
throw new FocasWireException("Truncated FOCAS response block length.");
var blockLength = BinaryPrimitives.ReadUInt16BigEndian(body.Slice(offset, 2));
if (blockLength < 0x10 || offset + blockLength > body.Length)
throw new FocasWireException($"Invalid FOCAS response block length {blockLength}.");
var block = body.Slice(offset, blockLength);
var command = BinaryPrimitives.ReadUInt16BigEndian(block.Slice(6, 2));
var payloadLength = BinaryPrimitives.ReadUInt16BigEndian(block.Slice(14, 2));
if (0x10 + payloadLength > blockLength)
throw new FocasWireException("Invalid FOCAS response payload length.");
var rc = BinaryPrimitives.ReadInt16BigEndian(block.Slice(8, 2));
blocks.Add(new ResponseBlock(command, rc, block.Slice(16, payloadLength).ToArray()));
offset += blockLength;
}
return blocks;
}
/// Read an ASCII string out of a payload span, stopping at the first NUL and trimming trailing spaces.
/// The bytes to decode.
/// The decoded ASCII string.
public static string ReadAscii(ReadOnlySpan bytes)
{
var end = bytes.IndexOf((byte)0);
if (end >= 0) bytes = bytes.Slice(0, end);
return Encoding.ASCII.GetString(bytes.ToArray()).TrimEnd(' ', '\0');
}
///
/// Read an axis/spindle name record — the first 2 bytes of a 2-byte (axis) or 4-byte
/// (spindle) slot. Trailing spaces and NULs are stripped so "X " becomes
/// "X".
///
/// The bytes to decode.
/// The decoded name record.
public static string ReadNameRecord(ReadOnlySpan bytes)
{
if (bytes.Length < 2) return string.Empty;
var buffer = bytes.Slice(0, Math.Min(2, bytes.Length)).ToArray();
return Encoding.ASCII.GetString(buffer).TrimEnd(' ', '\0');
}
private static byte[] BuildRequestBlock(RequestBlock request)
{
var extra = request.ExtraPayload ?? Array.Empty();
if (extra.Length > ushort.MaxValue)
throw new ArgumentOutOfRangeException(nameof(request), "FOCAS request extra payload is too large.");
var blockLength = 0x1c + extra.Length;
if (blockLength > ushort.MaxValue)
throw new ArgumentOutOfRangeException(nameof(request), "FOCAS request block is too large.");
var block = new byte[blockLength];
BinaryPrimitives.WriteUInt16BigEndian(block.AsSpan(0, 2), (ushort)blockLength);
BinaryPrimitives.WriteUInt16BigEndian(block.AsSpan(2, 2), request.RequestClass);
BinaryPrimitives.WriteUInt16BigEndian(block.AsSpan(4, 2), request.PathId);
BinaryPrimitives.WriteUInt16BigEndian(block.AsSpan(6, 2), request.Command);
BinaryPrimitives.WriteInt32BigEndian(block.AsSpan(8, 4), request.Arg1);
BinaryPrimitives.WriteInt32BigEndian(block.AsSpan(12, 4), request.Arg2);
BinaryPrimitives.WriteInt32BigEndian(block.AsSpan(16, 4), request.Arg3);
BinaryPrimitives.WriteInt32BigEndian(block.AsSpan(20, 4), request.Arg4);
BinaryPrimitives.WriteUInt16BigEndian(block.AsSpan(24, 2), request.Arg5);
BinaryPrimitives.WriteUInt16BigEndian(block.AsSpan(26, 2), (ushort)extra.Length);
extra.CopyTo(block.AsSpan(28));
return block;
}
}
/// One raw PDU off the wire — header bytes plus the body.
internal sealed record Pdu(byte Type, byte Direction, byte[] Body);
///
/// One request block within a type-0x21 PDU body. is the
/// FOCAS command ID (e.g. 0x0018 for sysinfo); ..
/// are the command-specific scalar arguments; carries the
/// optional extra bytes for writes.
///
internal sealed record RequestBlock(
ushort Command,
int Arg1 = 0,
int Arg2 = 0,
int Arg3 = 0,
int Arg4 = 0,
ushort Arg5 = 0,
ushort RequestClass = 1,
ushort PathId = 1,
byte[]? ExtraPayload = null);
/// One response block — command ID + FOCAS return code + payload bytes.
internal sealed record ResponseBlock(ushort Command, short Rc, byte[] Payload);