Phase 2: serial + RIO protocol core (RioJoy.Core) with unit tests
Port the RIO wire protocol from legacy/riovjoy2.cpp into testable C#: - Protocol/: command + length table, 7-bit checksum, packet builder, and a streaming receive-side framing state machine (PacketParser) that mirrors the legacy ReadCommBlock framing/resync (high-bit-mid-packet abort). Typed RIO->PC decodes: AnalogReport (14-bit sign-extend), VersionInfo, CheckStatus; lamp-state composition. - Serial/: RioSerialLink drives an async receive loop with ACK/NAK reply policy (legacy force-accept vs. opt-in VerifyInboundChecksum), the analog poll timer, and the >5s reset-recovery watchdog. IRioTransport abstracts the COM port; the SerialPort-backed transport does 9600 8N1 + DTR reset pulse, and acquire/release is just create/dispose (foundation for native-game serial yield). - tests/RioJoy.Core.Tests: 54 xUnit tests covering checksum, framing/resync, builder round-trips, analog sign-extension + sentinel rejection, lamp combos, and the read loop driven against an in-memory fake transport. Hardware verification (version/check/analog against a cabinet) remains; it can't be done off-device. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
namespace RioJoy.Core.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// The five raw analog axes decoded from an <see cref="RioCommand.AnalogReply"/>
|
||||
/// payload. Values are the 14-bit signed raw counts straight off the RIO, before
|
||||
/// any calibration/deadzone mapping (that math is Phase 4). Port of
|
||||
/// <c>AnalogEvent</c> / <c>CombinePair</c> (riovjoy2.cpp#L1116); see
|
||||
/// docs/PROTOCOL.md §4.
|
||||
/// </summary>
|
||||
public readonly struct AnalogReport
|
||||
{
|
||||
public short Throttle { get; }
|
||||
public short LeftPedal { get; }
|
||||
public short RightPedal { get; }
|
||||
public short JoystickY { get; }
|
||||
public short JoystickX { get; }
|
||||
|
||||
public AnalogReport(short throttle, short leftPedal, short rightPedal, short joystickY, short joystickX)
|
||||
{
|
||||
Throttle = throttle;
|
||||
LeftPedal = leftPedal;
|
||||
RightPedal = rightPedal;
|
||||
JoystickY = joystickY;
|
||||
JoystickX = joystickX;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decode a 14-bit signed axis value from a (low, high) byte pair, each
|
||||
/// carrying 7 data bits. Port of <c>CombinePair</c> (riovjoy2.cpp#L1116):
|
||||
/// combine the 7-bit halves then sign-extend bit 13.
|
||||
/// </summary>
|
||||
public static short CombinePair(byte low, byte high)
|
||||
{
|
||||
int raw = (low & 0x7F) | (high << 7); // 14 bits
|
||||
if ((raw & 0x2000) != 0) // bit 13 set → negative
|
||||
raw |= ~0x3FFF; // sign-extend into the high bits
|
||||
return (short)raw;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Try to decode an <see cref="RioCommand.AnalogReply"/> payload (10 bytes:
|
||||
/// 5 axes × low,high in the order throttle, left pedal, right pedal,
|
||||
/// joystick Y, joystick X). Returns <see langword="false"/> if any byte equals
|
||||
/// <c>0xFE</c> (<see cref="RioControl.Restart"/>), which the RIO uses as an
|
||||
/// "invalid sample" sentinel — the legacy code ignores such replies.
|
||||
/// </summary>
|
||||
public static bool TryParse(ReadOnlySpan<byte> payload, out AnalogReport report)
|
||||
{
|
||||
if (payload.Length != RioCommandTable.PayloadLength(RioCommand.AnalogReply))
|
||||
throw new ArgumentException(
|
||||
$"AnalogReply payload must be {RioCommandTable.PayloadLength(RioCommand.AnalogReply)} bytes.",
|
||||
nameof(payload));
|
||||
|
||||
foreach (byte b in payload)
|
||||
{
|
||||
if (b == (byte)RioControl.Restart)
|
||||
{
|
||||
report = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
report = new AnalogReport(
|
||||
throttle: CombinePair(payload[0], payload[1]),
|
||||
leftPedal: CombinePair(payload[2], payload[3]),
|
||||
rightPedal: CombinePair(payload[4], payload[5]),
|
||||
joystickY: CombinePair(payload[6], payload[7]),
|
||||
joystickX: CombinePair(payload[8], payload[9]));
|
||||
return true;
|
||||
}
|
||||
|
||||
public override string ToString() =>
|
||||
$"T:{Throttle} L:{LeftPedal} R:{RightPedal} Y:{JoystickY} X:{JoystickX}";
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
namespace RioJoy.Core.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// Builds outbound (PC → RIO) packets: <c>[command][payload…][checksum]</c>.
|
||||
/// Port of <c>SendCommand</c> (riovjoy2.cpp#L1217), which copies the command
|
||||
/// plus the command's fixed number of payload bytes, accumulating the 7-bit
|
||||
/// checksum, then appends the checksum byte.
|
||||
/// </summary>
|
||||
public static class PacketBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Build a wire-ready packet for <paramref name="command"/> with the given
|
||||
/// <paramref name="payload"/>. The payload length must match the command's
|
||||
/// entry in the length table.
|
||||
/// </summary>
|
||||
public static byte[] Build(RioCommand command, ReadOnlySpan<byte> payload)
|
||||
{
|
||||
int expected = RioCommandTable.PayloadLength(command);
|
||||
if (payload.Length != expected)
|
||||
throw new ArgumentException(
|
||||
$"{command} expects a {expected}-byte payload, got {payload.Length}.",
|
||||
nameof(payload));
|
||||
|
||||
var packet = new byte[1 + expected + 1];
|
||||
packet[0] = (byte)command;
|
||||
payload.CopyTo(packet.AsSpan(1));
|
||||
packet[^1] = RioChecksum.Compute(packet.AsSpan(0, 1 + expected));
|
||||
return packet;
|
||||
}
|
||||
|
||||
/// <summary>Build a zero-payload command packet (CheckRequest etc.).</summary>
|
||||
public static byte[] Build(RioCommand command) => Build(command, ReadOnlySpan<byte>.Empty);
|
||||
|
||||
// --- Convenience builders for the PC → RIO commands ---------------------
|
||||
|
||||
public static byte[] CheckRequest() => Build(RioCommand.CheckRequest);
|
||||
|
||||
public static byte[] VersionRequest() => Build(RioCommand.VersionRequest);
|
||||
|
||||
public static byte[] AnalogRequest() => Build(RioCommand.AnalogRequest);
|
||||
|
||||
public static byte[] ResetRequest(RioResetTarget target) =>
|
||||
Build(RioCommand.ResetRequest, stackalloc byte[] { (byte)target });
|
||||
|
||||
/// <summary>
|
||||
/// LampRequest: payload is <c>[lamp#, state]</c>. The state byte is a
|
||||
/// 7-bit lamp-state value (see <see cref="RioLampState"/>).
|
||||
/// </summary>
|
||||
public static byte[] LampRequest(byte lampNumber, byte state) =>
|
||||
Build(RioCommand.LampRequest, stackalloc byte[] { lampNumber, state });
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
namespace RioJoy.Core.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// Streaming receive-side framing state machine, fed raw bytes from the serial
|
||||
/// port. Faithful port of the framing loop in <c>ReadCommBlock</c>
|
||||
/// (riovjoy2.cpp#L860); see docs/PROTOCOL.md §2.
|
||||
///
|
||||
/// <para>Rules:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item>Outside a packet, a byte in the command range starts a packet whose
|
||||
/// total length is <c>1 + payloadLen + 1</c> (command + payload + checksum).</item>
|
||||
/// <item>Outside a packet, any other byte is reported as a
|
||||
/// <see cref="RioRxEventKind.ControlByte"/> (ACK/NAK/RESTART/IDLE or garbage).</item>
|
||||
/// <item>Mid-packet, a byte with the high bit set aborts the packet and
|
||||
/// resynchronizes (<see cref="RioRxEventKind.FramingError"/>); that byte is
|
||||
/// discarded, matching the legacy <c>continue</c>.</item>
|
||||
/// <item>When the final (checksum) byte arrives, a
|
||||
/// <see cref="RioRxEventKind.Packet"/> is emitted with
|
||||
/// <see cref="RioRxEvent.ChecksumValid"/> set from a real checksum compare.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// Each input byte produces at most one event, so the per-byte
|
||||
/// <see cref="Feed(byte, out RioRxEvent)"/> returns a single optional event.
|
||||
/// This type is not thread-safe; drive it from one reader.
|
||||
/// </summary>
|
||||
public sealed class PacketParser
|
||||
{
|
||||
// Max framed packet = command(1) + AnalogReply payload(10) + checksum(1) = 12.
|
||||
private const int MaxPacketBytes = 16;
|
||||
|
||||
private readonly byte[] _buffer = new byte[MaxPacketBytes];
|
||||
|
||||
// Bytes still expected to complete the current packet (0 = not in a packet).
|
||||
private int _remaining;
|
||||
|
||||
// Count of bytes stored in _buffer for the current packet (incl. command).
|
||||
private int _count;
|
||||
|
||||
/// <summary>True while a packet is partially received.</summary>
|
||||
public bool InPacket => _remaining != 0;
|
||||
|
||||
/// <summary>Discard any in-progress packet and reset to the idle state.</summary>
|
||||
public void Reset()
|
||||
{
|
||||
_remaining = 0;
|
||||
_count = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Feed one received byte. Returns <see langword="true"/> and sets
|
||||
/// <paramref name="ev"/> when this byte produced an event; otherwise returns
|
||||
/// <see langword="false"/> (the byte was consumed into an in-progress packet).
|
||||
/// </summary>
|
||||
public bool Feed(byte ch, out RioRxEvent ev)
|
||||
{
|
||||
if (_remaining != 0)
|
||||
{
|
||||
// Mid-packet. A high-bit byte is a framing error → abort and resync.
|
||||
if ((ch & 0x80) != 0)
|
||||
{
|
||||
Reset();
|
||||
ev = RioRxEvent.ForFramingError(ch);
|
||||
return true;
|
||||
}
|
||||
|
||||
_buffer[_count++] = ch;
|
||||
_remaining--;
|
||||
|
||||
if (_remaining == 0)
|
||||
{
|
||||
ev = CompletePacket();
|
||||
return true;
|
||||
}
|
||||
|
||||
ev = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Not in a packet.
|
||||
if (RioCommandTable.IsCommand(ch))
|
||||
{
|
||||
_count = 0;
|
||||
_buffer[_count++] = ch;
|
||||
_remaining = RioCommandTable.PayloadLength(ch) + 1; // + checksum byte
|
||||
ev = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
// A byte outside framing: control character (ACK/NAK/RESTART/IDLE) or garbage.
|
||||
ev = RioRxEvent.ForControl(ch);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Feed a chunk of received bytes, invoking <paramref name="onEvent"/> for
|
||||
/// each event produced, in order.
|
||||
/// </summary>
|
||||
public void Feed(ReadOnlySpan<byte> data, Action<RioRxEvent> onEvent)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(onEvent);
|
||||
foreach (byte ch in data)
|
||||
{
|
||||
if (Feed(ch, out RioRxEvent ev))
|
||||
onEvent(ev);
|
||||
}
|
||||
}
|
||||
|
||||
private RioRxEvent CompletePacket()
|
||||
{
|
||||
// _buffer = [command][payload…][checksum]; _count includes all of them.
|
||||
int bodyLen = _count - 1; // command + payload (exclude checksum)
|
||||
byte receivedChecksum = _buffer[bodyLen];
|
||||
byte computed = RioChecksum.Compute(_buffer.AsSpan(0, bodyLen));
|
||||
bool checksumValid = computed == receivedChecksum;
|
||||
|
||||
var command = (RioCommand)_buffer[0];
|
||||
var payload = _buffer.AsSpan(1, bodyLen - 1).ToArray(); // copy out; buffer is reused
|
||||
var packet = new RioPacket(command, payload);
|
||||
|
||||
Reset();
|
||||
return RioRxEvent.ForPacket(packet, checksumValid);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace RioJoy.Core.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// The RIO packet checksum: the low 7 bits of the sum of the low 7 bits of
|
||||
/// every byte in the command+payload (the checksum byte itself excluded).
|
||||
/// Port of the build loop in <c>SendCommand</c> (riovjoy2.cpp#L1227) and the
|
||||
/// verify loop in <c>ReadCommBlock</c> (riovjoy2.cpp#L884); see docs/PROTOCOL.md §2.
|
||||
/// </summary>
|
||||
public static class RioChecksum
|
||||
{
|
||||
/// <summary>
|
||||
/// Compute the 7-bit checksum over <paramref name="commandAndPayload"/>
|
||||
/// (the command byte followed by its payload bytes — not the checksum byte).
|
||||
/// </summary>
|
||||
public static byte Compute(ReadOnlySpan<byte> commandAndPayload)
|
||||
{
|
||||
byte sum = 0;
|
||||
foreach (byte b in commandAndPayload)
|
||||
sum += (byte)(b & 0x7F);
|
||||
|
||||
return (byte)(sum & 0x7F);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
namespace RioJoy.Core.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// RIO message commands. The command byte always has its high bit set; the
|
||||
/// value identifies the message. Faithful port of <c>enum RIOCommand</c> and
|
||||
/// <c>g_baRIOLengthsA</c> in <c>legacy/riovjoy2.cpp</c> (see docs/PROTOCOL.md §3).
|
||||
/// </summary>
|
||||
public enum RioCommand : byte
|
||||
{
|
||||
// PC → RIO
|
||||
CheckRequest = 0x80,
|
||||
VersionRequest = 0x81,
|
||||
AnalogRequest = 0x82,
|
||||
ResetRequest = 0x83,
|
||||
LampRequest = 0x84,
|
||||
|
||||
// RIO → PC
|
||||
CheckReply = 0x85,
|
||||
VersionReply = 0x86,
|
||||
AnalogReply = 0x87,
|
||||
ButtonPressed = 0x88,
|
||||
ButtonReleased = 0x89,
|
||||
KeyPressed = 0x8A,
|
||||
KeyReleased = 0x8B,
|
||||
TestModeChange = 0x8C,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single-byte control characters that live <i>outside</i> packet framing
|
||||
/// (legacy <c>#define ACK_CHAR</c> … <c>IDLE_CHAR</c>, riovjoy2.cpp#L154).
|
||||
/// </summary>
|
||||
public enum RioControl : byte
|
||||
{
|
||||
/// <summary>Packet accepted.</summary>
|
||||
Ack = 0xFC,
|
||||
|
||||
/// <summary>Packet rejected; resend.</summary>
|
||||
Nak = 0xFD,
|
||||
|
||||
/// <summary>Restart; also used as an in-payload "invalid" sentinel.</summary>
|
||||
Restart = 0xFE,
|
||||
|
||||
/// <summary>Idle.</summary>
|
||||
Idle = 0xFF,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reset targets for <see cref="RioCommand.ResetRequest"/> (docs/PROTOCOL.md §3).
|
||||
/// </summary>
|
||||
public enum RioResetTarget : byte
|
||||
{
|
||||
All = 0,
|
||||
Throttle = 1,
|
||||
LeftPedal = 2,
|
||||
RightPedal = 3,
|
||||
VerticalJoystick = 4, // Y
|
||||
HorizontalJoystick = 5, // X
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The <c>statusType</c> field of <see cref="RioCommand.CheckReply"/>
|
||||
/// (legacy <c>enum RIOStatusType</c>, riovjoy2.cpp#L204).
|
||||
/// </summary>
|
||||
public enum RioStatusType : byte
|
||||
{
|
||||
BoardOk = 0,
|
||||
BoardMissing = 1,
|
||||
BoardBad = 2,
|
||||
LampBad = 3,
|
||||
RestartCount = 4,
|
||||
AbandonCount = 5,
|
||||
FullBufferCount = 6,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-command payload lengths (bytes between the command byte and the
|
||||
/// checksum byte). Port of <c>g_baRIOLengthsA</c>.
|
||||
/// </summary>
|
||||
public static class RioCommandTable
|
||||
{
|
||||
/// <summary>Lowest command byte value (<see cref="RioCommand.CheckRequest"/>).</summary>
|
||||
public const byte Base = (byte)RioCommand.CheckRequest;
|
||||
|
||||
// Indexed by (command - Base). Order must match RioCommand.
|
||||
private static readonly byte[] PayloadLengths =
|
||||
{
|
||||
0, // CheckRequest
|
||||
0, // VersionRequest
|
||||
0, // AnalogRequest
|
||||
1, // ResetRequest
|
||||
2, // LampRequest
|
||||
2, // CheckReply
|
||||
2, // VersionReply
|
||||
10, // AnalogReply
|
||||
1, // ButtonPressed
|
||||
1, // ButtonReleased
|
||||
2, // KeyPressed
|
||||
2, // KeyReleased
|
||||
1, // TestModeChange
|
||||
};
|
||||
|
||||
/// <summary>Number of defined commands.</summary>
|
||||
public static int Count => PayloadLengths.Length;
|
||||
|
||||
/// <summary>Highest valid command byte value (inclusive).</summary>
|
||||
public static byte Max => (byte)(Base + Count - 1);
|
||||
|
||||
/// <summary>True if <paramref name="commandByte"/> is a known command.</summary>
|
||||
public static bool IsCommand(byte commandByte) =>
|
||||
commandByte >= Base && commandByte <= Max;
|
||||
|
||||
/// <summary>
|
||||
/// Payload length for a command byte. Throws if the byte is not a known
|
||||
/// command — callers framing a stream should guard with
|
||||
/// <see cref="IsCommand"/> first.
|
||||
/// </summary>
|
||||
public static int PayloadLength(byte commandByte)
|
||||
{
|
||||
if (!IsCommand(commandByte))
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(commandByte),
|
||||
$"0x{commandByte:X2} is not a RIO command (valid 0x{Base:X2}..0x{Max:X2}).");
|
||||
|
||||
return PayloadLengths[commandByte - Base];
|
||||
}
|
||||
|
||||
/// <summary>Payload length for a <see cref="RioCommand"/>.</summary>
|
||||
public static int PayloadLength(RioCommand command) => PayloadLength((byte)command);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
namespace RioJoy.Core.Protocol;
|
||||
|
||||
/// <summary>Flash mode for a lighted button (lamp-state bits 0–1).</summary>
|
||||
public enum LampFlash : byte
|
||||
{
|
||||
Solid = 0,
|
||||
FlashSlow = 1,
|
||||
FlashMed = 2,
|
||||
FlashFast = 3,
|
||||
}
|
||||
|
||||
/// <summary>Lamp brightness "field 1" (bits 2–3).</summary>
|
||||
public enum LampField1 : byte
|
||||
{
|
||||
Off = 0x00,
|
||||
Dim = 0x04,
|
||||
Bright = 0x0C,
|
||||
}
|
||||
|
||||
/// <summary>Lamp brightness "field 2" (bits 4–5).</summary>
|
||||
public enum LampField2 : byte
|
||||
{
|
||||
Off = 0x00,
|
||||
Dim = 0x10,
|
||||
Bright = 0x30,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The <c>state</c> byte of a <see cref="RioCommand.LampRequest"/>, composed of
|
||||
/// a flash mode plus two brightness fields. Port of <c>enum LampState</c>
|
||||
/// (riovjoy2.cpp#L213); see docs/PROTOCOL.md §3.
|
||||
/// </summary>
|
||||
public static class RioLampState
|
||||
{
|
||||
/// <summary>Compose a lamp-state byte from its flash + brightness parts.</summary>
|
||||
public static byte Compose(LampFlash flash, LampField1 field1, LampField2 field2) =>
|
||||
(byte)((byte)flash + (byte)field1 + (byte)field2);
|
||||
|
||||
/// <summary>Lamp off: <c>SolidOff</c> (0x00).</summary>
|
||||
public static readonly byte SolidOff =
|
||||
Compose(LampFlash.Solid, LampField1.Off, LampField2.Off);
|
||||
|
||||
/// <summary>Dim solid: <c>SolidDim</c> (0x14). Used for idle/released lamps.</summary>
|
||||
public static readonly byte SolidDim =
|
||||
Compose(LampFlash.Solid, LampField1.Dim, LampField2.Dim);
|
||||
|
||||
/// <summary>Bright solid: <c>SolidBright</c> (0x3C). Used for pressed lamps.</summary>
|
||||
public static readonly byte SolidBright =
|
||||
Compose(LampFlash.Solid, LampField1.Bright, LampField2.Bright);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace RioJoy.Core.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// A fully-framed RIO packet: a command plus its fixed-length payload (the
|
||||
/// 7-bit data bytes between the command byte and the checksum). The checksum
|
||||
/// itself is not stored — it is validated/derived at the framing boundary.
|
||||
/// </summary>
|
||||
public readonly struct RioPacket
|
||||
{
|
||||
/// <summary>The command byte.</summary>
|
||||
public RioCommand Command { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The payload bytes (high bit always clear). Length matches
|
||||
/// <see cref="RioCommandTable.PayloadLength(RioCommand)"/>.
|
||||
/// </summary>
|
||||
public ReadOnlyMemory<byte> Payload { get; }
|
||||
|
||||
public RioPacket(RioCommand command, ReadOnlyMemory<byte> payload)
|
||||
{
|
||||
int expected = RioCommandTable.PayloadLength(command);
|
||||
if (payload.Length != expected)
|
||||
throw new ArgumentException(
|
||||
$"{command} expects a {expected}-byte payload, got {payload.Length}.",
|
||||
nameof(payload));
|
||||
|
||||
Command = command;
|
||||
Payload = payload;
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
var hex = Convert.ToHexString(Payload.Span);
|
||||
return Payload.IsEmpty ? Command.ToString() : $"{Command} [{hex}]";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
namespace RioJoy.Core.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// The RIO firmware version from a <see cref="RioCommand.VersionReply"/>
|
||||
/// (port of <c>VersionReply</c>, riovjoy2.cpp#L1484: payload is [major, minor]).
|
||||
/// </summary>
|
||||
public readonly struct VersionInfo
|
||||
{
|
||||
public byte Major { get; }
|
||||
public byte Minor { get; }
|
||||
|
||||
public VersionInfo(byte major, byte minor)
|
||||
{
|
||||
Major = major;
|
||||
Minor = minor;
|
||||
}
|
||||
|
||||
/// <summary>Decode a <see cref="RioCommand.VersionReply"/> payload (2 bytes).</summary>
|
||||
public static VersionInfo Parse(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
Require(payload, RioCommand.VersionReply);
|
||||
return new VersionInfo(payload[0], payload[1]);
|
||||
}
|
||||
|
||||
public override string ToString() => $"{Major}.{Minor}";
|
||||
|
||||
internal static void Require(ReadOnlySpan<byte> payload, RioCommand command)
|
||||
{
|
||||
int expected = RioCommandTable.PayloadLength(command);
|
||||
if (payload.Length != expected)
|
||||
throw new ArgumentException(
|
||||
$"{command} payload must be {expected} bytes.", nameof(payload));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A board/lamp status item from a <see cref="RioCommand.CheckReply"/>
|
||||
/// (port of <c>CheckReply</c>, riovjoy2.cpp#L1303: payload is [statusType, number]).
|
||||
/// The <see cref="Number"/> is a board number, lamp number, or counter depending
|
||||
/// on <see cref="Type"/>.
|
||||
/// </summary>
|
||||
public readonly struct CheckStatus
|
||||
{
|
||||
public RioStatusType Type { get; }
|
||||
public byte Number { get; }
|
||||
|
||||
public CheckStatus(RioStatusType type, byte number)
|
||||
{
|
||||
Type = type;
|
||||
Number = number;
|
||||
}
|
||||
|
||||
/// <summary>Decode a <see cref="RioCommand.CheckReply"/> payload (2 bytes).</summary>
|
||||
public static CheckStatus Parse(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
VersionInfo.Require(payload, RioCommand.CheckReply);
|
||||
return new CheckStatus((RioStatusType)payload[0], payload[1]);
|
||||
}
|
||||
|
||||
public override string ToString() => $"{Type}:{Number}";
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
namespace RioJoy.Core.Protocol;
|
||||
|
||||
/// <summary>What a <see cref="PacketParser"/> emitted for an input byte.</summary>
|
||||
public enum RioRxEventKind
|
||||
{
|
||||
/// <summary>A complete packet was framed (see <see cref="RioRxEvent.Packet"/>).</summary>
|
||||
Packet,
|
||||
|
||||
/// <summary>
|
||||
/// A single byte arrived outside packet framing: a control character
|
||||
/// (ACK/NAK/RESTART/IDLE) or stray garbage. See <see cref="RioRxEvent.Byte"/>.
|
||||
/// </summary>
|
||||
ControlByte,
|
||||
|
||||
/// <summary>
|
||||
/// A byte with the high bit set arrived mid-packet — a framing error. The
|
||||
/// in-progress packet was aborted and the parser resynchronized. Port of the
|
||||
/// <c>(ch & 0x80)</c> abort in <c>ReadCommBlock</c> (riovjoy2.cpp#L871).
|
||||
/// </summary>
|
||||
FramingError,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One event produced by <see cref="PacketParser"/>. A given input byte yields
|
||||
/// at most one event.
|
||||
/// </summary>
|
||||
public readonly struct RioRxEvent
|
||||
{
|
||||
public RioRxEventKind Kind { get; }
|
||||
|
||||
/// <summary>The framed packet (valid when <see cref="Kind"/> is <see cref="RioRxEventKind.Packet"/>).</summary>
|
||||
public RioPacket Packet { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the received checksum matched. Valid when <see cref="Kind"/> is
|
||||
/// <see cref="RioRxEventKind.Packet"/>. The legacy receive path force-accepts
|
||||
/// regardless (see docs/PROTOCOL.md §2 ⚠️); this flag exposes the real result
|
||||
/// so callers can choose to NAK.
|
||||
/// </summary>
|
||||
public bool ChecksumValid { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The single byte (valid when <see cref="Kind"/> is
|
||||
/// <see cref="RioRxEventKind.ControlByte"/> or
|
||||
/// <see cref="RioRxEventKind.FramingError"/>).
|
||||
/// </summary>
|
||||
public byte Byte { get; }
|
||||
|
||||
private RioRxEvent(RioRxEventKind kind, RioPacket packet, bool checksumValid, byte b)
|
||||
{
|
||||
Kind = kind;
|
||||
Packet = packet;
|
||||
ChecksumValid = checksumValid;
|
||||
Byte = b;
|
||||
}
|
||||
|
||||
internal static RioRxEvent ForPacket(RioPacket packet, bool checksumValid) =>
|
||||
new(RioRxEventKind.Packet, packet, checksumValid, 0);
|
||||
|
||||
internal static RioRxEvent ForControl(byte b) =>
|
||||
new(RioRxEventKind.ControlByte, default, false, b);
|
||||
|
||||
internal static RioRxEvent ForFramingError(byte b) =>
|
||||
new(RioRxEventKind.FramingError, default, false, b);
|
||||
|
||||
/// <summary>True if this is a control byte equal to <paramref name="control"/>.</summary>
|
||||
public bool IsControl(RioControl control) =>
|
||||
Kind == RioRxEventKind.ControlByte && Byte == (byte)control;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace RioJoy.Core.Serial;
|
||||
|
||||
/// <summary>
|
||||
/// A bidirectional byte transport to the RIO board. Abstracts the physical COM
|
||||
/// port so <see cref="RioSerialLink"/> can be driven against an in-memory fake
|
||||
/// in tests. Disposing the transport releases the underlying port — which is how
|
||||
/// RIOJoy yields the COM port to a native game (see docs/PLAN.md §Profiles).
|
||||
/// </summary>
|
||||
public interface IRioTransport : IDisposable
|
||||
{
|
||||
/// <summary>Human-readable description (e.g. "COM3 @ 9600 8N1").</summary>
|
||||
string Description { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Read available bytes into <paramref name="buffer"/>. Returns the number of
|
||||
/// bytes read; a return of 0 indicates the transport has closed.
|
||||
/// </summary>
|
||||
ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Write all of <paramref name="data"/> to the transport.</summary>
|
||||
ValueTask WriteAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
using System.Diagnostics;
|
||||
using RioJoy.Core.Protocol;
|
||||
|
||||
namespace RioJoy.Core.Serial;
|
||||
|
||||
/// <summary>
|
||||
/// Drives the RIO serial link: a receive loop that frames incoming bytes into
|
||||
/// packets (with ACK/NAK replies), plus the analog poll + reset-recovery timer.
|
||||
/// The modern equivalent of the legacy overlapped-I/O watch thread
|
||||
/// (<c>CommWatchProc</c>/<c>ReadCommBlock</c>); see docs/PROTOCOL.md §2 and §4.
|
||||
///
|
||||
/// <para>The link drives a supplied <see cref="IRioTransport"/> but does not own
|
||||
/// it: acquiring/releasing the COM port is the transport's lifecycle (create to
|
||||
/// acquire, dispose to yield).</para>
|
||||
/// </summary>
|
||||
public sealed class RioSerialLink
|
||||
{
|
||||
private readonly IRioTransport _transport;
|
||||
private readonly RioSerialLinkOptions _options;
|
||||
private readonly PacketParser _parser = new();
|
||||
private readonly SemaphoreSlim _writeLock = new(1, 1);
|
||||
|
||||
// Time since the last accepted AnalogReply, for the recovery watchdog.
|
||||
private readonly Stopwatch _sinceAnalog = new();
|
||||
|
||||
public RioSerialLink(IRioTransport transport, RioSerialLinkOptions? options = null)
|
||||
{
|
||||
_transport = transport ?? throw new ArgumentNullException(nameof(transport));
|
||||
_options = options ?? new RioSerialLinkOptions();
|
||||
}
|
||||
|
||||
/// <summary>Raised for every framed packet (after the ACK/NAK reply is sent).</summary>
|
||||
public event Action<RioPacket>? PacketReceived;
|
||||
|
||||
/// <summary>Raised for a decoded, valid <see cref="RioCommand.AnalogReply"/>.</summary>
|
||||
public event Action<AnalogReport>? AnalogReceived;
|
||||
|
||||
/// <summary>Raised for a decoded <see cref="RioCommand.VersionReply"/>.</summary>
|
||||
public event Action<VersionInfo>? VersionReceived;
|
||||
|
||||
/// <summary>Raised for a decoded <see cref="RioCommand.CheckReply"/>.</summary>
|
||||
public event Action<CheckStatus>? CheckReceived;
|
||||
|
||||
/// <summary>Raised for a control byte received outside framing (ACK/NAK/RESTART/IDLE/garbage).</summary>
|
||||
public event Action<byte>? ControlReceived;
|
||||
|
||||
/// <summary>Raised when a mid-packet framing error forced a resync.</summary>
|
||||
public event Action? FramingError;
|
||||
|
||||
/// <summary>The transport's description, surfaced for status/logging.</summary>
|
||||
public string Description => _transport.Description;
|
||||
|
||||
/// <summary>
|
||||
/// Run the receive loop and (if enabled) the analog poll loop until
|
||||
/// <paramref name="cancellationToken"/> fires or the transport closes.
|
||||
/// </summary>
|
||||
public async Task RunAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_parser.Reset();
|
||||
_sinceAnalog.Restart();
|
||||
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
|
||||
var loops = new List<Task> { ReceiveLoopAsync(linked.Token) };
|
||||
if (_options.AutoPollAnalog)
|
||||
loops.Add(PollLoopAsync(linked.Token));
|
||||
|
||||
try
|
||||
{
|
||||
// If any running loop ends (transport closed / error / cancellation),
|
||||
// tear the others down too.
|
||||
await Task.WhenAny(loops).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
linked.Cancel();
|
||||
await Task.WhenAll(loops.Select(Swallow)).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Send a pre-built packet (see <see cref="PacketBuilder"/>) to the RIO.</summary>
|
||||
public async Task SendAsync(ReadOnlyMemory<byte> packet, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await _transport.WriteAsync(packet, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Request an analog update (<see cref="RioCommand.AnalogRequest"/>).</summary>
|
||||
public Task RequestAnalogAsync(CancellationToken cancellationToken = default) =>
|
||||
SendAsync(PacketBuilder.AnalogRequest(), cancellationToken);
|
||||
|
||||
/// <summary>Request the RIO firmware version (<see cref="RioCommand.VersionRequest"/>).</summary>
|
||||
public Task RequestVersionAsync(CancellationToken cancellationToken = default) =>
|
||||
SendAsync(PacketBuilder.VersionRequest(), cancellationToken);
|
||||
|
||||
/// <summary>Request a board/lamp status check (<see cref="RioCommand.CheckRequest"/>).</summary>
|
||||
public Task RequestCheckAsync(CancellationToken cancellationToken = default) =>
|
||||
SendAsync(PacketBuilder.CheckRequest(), cancellationToken);
|
||||
|
||||
/// <summary>Issue a reset to recalibrate an axis or recover the board.</summary>
|
||||
public Task ResetAsync(RioResetTarget target, CancellationToken cancellationToken = default) =>
|
||||
SendAsync(PacketBuilder.ResetRequest(target), cancellationToken);
|
||||
|
||||
/// <summary>Set a lighted button's state (<see cref="RioCommand.LampRequest"/>).</summary>
|
||||
public Task SetLampAsync(byte lampNumber, byte state, CancellationToken cancellationToken = default) =>
|
||||
SendAsync(PacketBuilder.LampRequest(lampNumber, state), cancellationToken);
|
||||
|
||||
private async Task ReceiveLoopAsync(CancellationToken ct)
|
||||
{
|
||||
var buffer = new byte[_options.ReadBufferSize];
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
int n = await _transport.ReadAsync(buffer, ct).ConfigureAwait(false);
|
||||
if (n == 0)
|
||||
break; // transport closed
|
||||
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
if (_parser.Feed(buffer[i], out RioRxEvent ev))
|
||||
await HandleEventAsync(ev, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleEventAsync(RioRxEvent ev, CancellationToken ct)
|
||||
{
|
||||
switch (ev.Kind)
|
||||
{
|
||||
case RioRxEventKind.Packet:
|
||||
await ReplyAsync(ev, ct).ConfigureAwait(false);
|
||||
|
||||
DispatchTyped(ev.Packet);
|
||||
PacketReceived?.Invoke(ev.Packet);
|
||||
break;
|
||||
|
||||
case RioRxEventKind.ControlByte:
|
||||
ControlReceived?.Invoke(ev.Byte);
|
||||
break;
|
||||
|
||||
case RioRxEventKind.FramingError:
|
||||
FramingError?.Invoke();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void DispatchTyped(RioPacket packet)
|
||||
{
|
||||
switch (packet.Command)
|
||||
{
|
||||
case RioCommand.AnalogReply:
|
||||
if (AnalogReport.TryParse(packet.Payload.Span, out AnalogReport report))
|
||||
{
|
||||
_sinceAnalog.Restart();
|
||||
AnalogReceived?.Invoke(report);
|
||||
}
|
||||
break;
|
||||
|
||||
case RioCommand.VersionReply:
|
||||
VersionReceived?.Invoke(VersionInfo.Parse(packet.Payload.Span));
|
||||
break;
|
||||
|
||||
case RioCommand.CheckReply:
|
||||
CheckReceived?.Invoke(CheckStatus.Parse(packet.Payload.Span));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private Task ReplyAsync(RioRxEvent ev, CancellationToken ct)
|
||||
{
|
||||
// Documented contract: ACK an accepted packet; NAK a button packet whose
|
||||
// checksum failed. The legacy path force-accepts (always ACK) unless
|
||||
// VerifyInboundChecksum re-enables real verification.
|
||||
bool nak = _options.VerifyInboundChecksum
|
||||
&& !ev.ChecksumValid
|
||||
&& ev.Packet.Command is RioCommand.ButtonPressed or RioCommand.ButtonReleased;
|
||||
|
||||
byte reply = nak ? (byte)RioControl.Nak : (byte)RioControl.Ack;
|
||||
return SendAsync(new[] { reply }, ct);
|
||||
}
|
||||
|
||||
private async Task PollLoopAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(_options.AnalogPollInterval, ct).ConfigureAwait(false);
|
||||
|
||||
await RequestAnalogAsync(ct).ConfigureAwait(false);
|
||||
|
||||
if (_sinceAnalog.Elapsed > _options.AnalogRecoveryTimeout)
|
||||
{
|
||||
// No analog data for too long — recover with a general reset.
|
||||
await SendAsync(PacketBuilder.ResetRequest(RioResetTarget.All), ct).ConfigureAwait(false);
|
||||
_sinceAnalog.Restart(); // avoid re-issuing every tick
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Normal shutdown.
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task Swallow(Task task)
|
||||
{
|
||||
try
|
||||
{
|
||||
await task.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected on teardown.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace RioJoy.Core.Serial;
|
||||
|
||||
/// <summary>Tunables for <see cref="RioSerialLink"/>.</summary>
|
||||
public sealed record RioSerialLinkOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// How often to request an analog update while the link runs. The legacy
|
||||
/// watch thread polls on a ~55 ms timeout (riovjoy2.cpp#L1069).
|
||||
/// </summary>
|
||||
public TimeSpan AnalogPollInterval { get; init; } = TimeSpan.FromMilliseconds(55);
|
||||
|
||||
/// <summary>
|
||||
/// If no <see cref="Protocol.RioCommand.AnalogReply"/> arrives within this
|
||||
/// window, issue a general reset to recover (legacy >5 s rule,
|
||||
/// riovjoy2.cpp#L1096).
|
||||
/// </summary>
|
||||
public TimeSpan AnalogRecoveryTimeout { get; init; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>Whether the link should automatically poll for analog updates.</summary>
|
||||
public bool AutoPollAnalog { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// When <see langword="true"/>, a button packet that fails its checksum is
|
||||
/// NAK'd (the documented protocol). When <see langword="false"/> (default),
|
||||
/// every framed packet is ACK'd regardless of checksum, matching the legacy
|
||||
/// receive path which force-accepts (see docs/PROTOCOL.md §2 ⚠️).
|
||||
/// </summary>
|
||||
public bool VerifyInboundChecksum { get; init; }
|
||||
|
||||
/// <summary>Read buffer size for the receive loop.</summary>
|
||||
public int ReadBufferSize { get; init; } = 256;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.IO.Ports;
|
||||
|
||||
namespace RioJoy.Core.Serial;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IRioTransport"/> backed by a physical <see cref="SerialPort"/>.
|
||||
/// Opens the port at the RIO's 9600 8N1 settings and pulses DTR on open as a
|
||||
/// board reset/handshake (SETDTR, 50 ms, CLRDTR — port of
|
||||
/// <c>OpenConnection</c>/<c>SetupConnection</c>, riovjoy2.cpp#L747; see
|
||||
/// docs/PROTOCOL.md §1).
|
||||
/// </summary>
|
||||
public sealed class SerialPortTransport : IRioTransport
|
||||
{
|
||||
/// <summary>RIO link bit rate.</summary>
|
||||
public const int BaudRate = 9600;
|
||||
|
||||
/// <summary>DTR reset-pulse hold time on open.</summary>
|
||||
public static readonly TimeSpan DtrPulse = TimeSpan.FromMilliseconds(50);
|
||||
|
||||
private readonly SerialPort _port;
|
||||
private readonly Stream _stream;
|
||||
|
||||
public SerialPortTransport(string portName)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(portName);
|
||||
|
||||
_port = new SerialPort(portName, BaudRate, Parity.None, 8, StopBits.One)
|
||||
{
|
||||
Handshake = Handshake.None,
|
||||
// The framing state machine handles timing; keep reads non-throwing.
|
||||
ReadTimeout = SerialPort.InfiniteTimeout,
|
||||
WriteTimeout = SerialPort.InfiniteTimeout,
|
||||
};
|
||||
|
||||
_port.Open();
|
||||
|
||||
// DTR reset pulse: assert, hold, release.
|
||||
_port.DtrEnable = true;
|
||||
Thread.Sleep(DtrPulse);
|
||||
_port.DtrEnable = false;
|
||||
|
||||
_stream = _port.BaseStream;
|
||||
}
|
||||
|
||||
public string Description => $"{_port.PortName} @ {BaudRate} 8N1";
|
||||
|
||||
public ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken) =>
|
||||
_stream.ReadAsync(buffer, cancellationToken);
|
||||
|
||||
public ValueTask WriteAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken) =>
|
||||
_stream.WriteAsync(data, cancellationToken);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// Disposing closes and releases the COM port for other owners.
|
||||
_port.Dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user