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);