namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
///
/// Modbus RTU framing: builds a request ADU ([unitId] + PDU + CRC) and deframes a
/// response back to its bare PDU. Byte-stream-agnostic — it operates on a
/// and knows nothing about sockets or serial ports, so a future serial transport can reuse it.
///
///
///
/// The single genuinely-new correctness risk of the RTU path: an RTU frame carries no
/// length field (unlike TCP's MBAP header), so the response size must be derived from
/// the function code. After reading addr(1) + fc(1), the remaining byte count
/// is one of three shapes:
///
///
/// ShapeTrailing bytes after addr,fc
/// -
/// Exception (fc & 0x80)
/// excCode(1) + CRC(2)
///
/// -
/// Read (FC 01/02/03/04)
/// byteCount(1) then byteCount data bytes + CRC(2)
///
/// -
/// Write echo (FC 05/06/15/16)
/// fixed 4 bytes + CRC(2)
///
///
///
/// The trailing CRC is validated over [addr, ...pdu] (everything except the two CRC
/// bytes) before the frame is interpreted — so a corrupt exception frame surfaces as a
/// desync, never as a bogus . A CRC mismatch or a short read
/// (truncation / stream closed mid-frame) throws ;
/// a CRC-valid exception PDU throws carrying the original
/// function code (fc & 0x7F) and exception code.
///
///
/// Once the CRC passes, the response's address byte is validated against the addressed unit.
/// On an RS-485 multi-drop bus a CRC-valid reply from the wrong slave would otherwise be
/// silently accepted as the addressed unit's data; such a frame is a unit-mismatch
/// . This ordering is deliberate — a corrupt frame
/// stays a CRC/desync failure, and only a coherent-but-mis-addressed frame becomes a
/// unit-mismatch desync.
///
///
public static class ModbusRtuFraming
{
///
/// Builds an RTU request ADU: the unit id, the PDU, and the trailing CRC-16/MODBUS
/// (appended low byte first).
///
/// The RTU slave/unit address.
/// The bare PDU (function code + data).
/// A new array: [unitId, ...pdu, crcLo, crcHi].
public static byte[] BuildAdu(byte unitId, ReadOnlySpan pdu)
{
var frame = new byte[1 + pdu.Length];
frame[0] = unitId;
pdu.CopyTo(frame.AsSpan(1));
return ModbusCrc.Append(frame);
}
///
/// Reads a single RTU response frame from and returns its bare PDU
/// ([fc, ...data]), with the leading unit-id and trailing CRC stripped.
///
/// The byte stream to read the response from.
///
/// The unit id the request was addressed to. The response's address byte is validated against
/// it after the CRC passes; a mismatch is a unit-mismatch desync.
///
/// A token to cancel the read.
/// The bare response PDU (function code followed by its data).
///
/// The frame was truncated (stream closed mid-frame), its trailing CRC did not validate, or its
/// address byte did not match .
///
///
/// The frame was a CRC-valid Modbus exception PDU (high bit set on the function code).
///
public static async Task ReadResponsePduAsync(
Stream stream, byte expectedUnit, CancellationToken ct)
{
// Header: addr(1) + fc(1). The function code selects how many more bytes to read.
var header = new byte[2];
await ReadExactlyAsync(stream, header, ct).ConfigureAwait(false);
var fc = header[1];
int trailing; // bytes after addr+fc, up to and including the 2 CRC bytes.
if ((fc & 0x80) != 0)
{
// Exception frame: excCode(1) + CRC(2).
trailing = 1 + 2;
}
else if (fc is 0x01 or 0x02 or 0x03 or 0x04)
{
// Read response: byteCount(1) then byteCount data bytes + CRC(2).
var bc = new byte[1];
await ReadExactlyAsync(stream, bc, ct).ConfigureAwait(false);
var byteCount = bc[0];
var rest = new byte[byteCount + 2];
await ReadExactlyAsync(stream, rest, ct).ConfigureAwait(false);
return ValidateAndStrip(expectedUnit, header, bc, rest);
}
else
{
// Fixed 4-byte echo: correct for the write FCs this driver emits (05/06/0F/10). A future
// variable-length response FC outside 01-04 (e.g. FC23) would be mis-sized here and
// surface as a desync — revisit the FC-shape table if the driver starts emitting one.
trailing = 4 + 2;
}
var tail = new byte[trailing];
await ReadExactlyAsync(stream, tail, ct).ConfigureAwait(false);
return ValidateAndStrip(expectedUnit, header, ReadOnlySpan.Empty, tail);
}
///
/// Assembles the full frame from its pieces, validates the trailing CRC over everything
/// except the CRC bytes, then validates the response address against
/// and strips addr + CRC to return the bare PDU.
/// A CRC-valid exception PDU throws .
///
private static byte[] ValidateAndStrip(
byte expectedUnit, ReadOnlySpan header, ReadOnlySpan middle, ReadOnlySpan tail)
{
// Reassemble the whole frame: header(addr,fc) + middle(optional byteCount) + tail.
var frame = new byte[header.Length + middle.Length + tail.Length];
header.CopyTo(frame);
middle.CopyTo(frame.AsSpan(header.Length));
tail.CopyTo(frame.AsSpan(header.Length + middle.Length));
// CRC covers everything except the trailing 2 CRC bytes.
var body = frame.AsSpan(0, frame.Length - 2);
var expected = ModbusCrc.Compute(body);
var actual = (ushort)(frame[^2] | (frame[^1] << 8));
if (actual != expected)
throw new ModbusTransportDesyncException(
ModbusTransportDesyncException.DesyncReason.TruncatedFrame,
$"Modbus RTU CRC mismatch: computed {expected:X4} got {actual:X4}");
// Unit-address validation runs only AFTER the CRC passes: a corrupt frame is a CRC/desync
// failure, and only a coherent-but-mis-addressed frame (a CRC-valid reply from the wrong
// RS-485 slave) is a unit-mismatch desync. Never silently accept another unit's data.
var addr = frame[0];
if (addr != expectedUnit)
throw new ModbusTransportDesyncException(
ModbusTransportDesyncException.DesyncReason.TruncatedFrame,
$"Modbus RTU unit mismatch: expected {expectedUnit} got {addr}");
// Bare PDU = frame minus leading addr and trailing CRC.
var pdu = body[1..].ToArray();
// Exception PDU: high bit set on the function code. The CRC already validated, so this is a
// coherent protocol-level error — surface the ORIGINAL function code (fc & 0x7F).
if ((pdu[0] & 0x80) != 0)
{
var fc = (byte)(pdu[0] & 0x7F);
var exCode = pdu[1];
throw new ModbusException(fc, exCode, $"Modbus exception fc={fc} code={exCode}");
}
return pdu;
}
///
/// Fills completely from , throwing a
/// truncation desync if the stream ends first. Mirrors
/// ModbusTcpTransport.ReadExactlyAsync, but normalises the end-of-stream to a
/// so a length-less RTU frame that arrives
/// short is classified as a desync rather than a bare .
///
private static async Task ReadExactlyAsync(Stream stream, byte[] buf, CancellationToken ct)
{
var read = 0;
while (read < buf.Length)
{
var n = await stream.ReadAsync(buf.AsMemory(read), ct).ConfigureAwait(false);
if (n == 0)
throw new ModbusTransportDesyncException(
ModbusTransportDesyncException.DesyncReason.TruncatedFrame,
$"Modbus RTU frame truncated: expected {buf.Length} bytes, got {read}");
read += n;
}
}
}