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,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