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:
@@ -16,6 +16,7 @@ Red Planet — talk to the RIO directly and do not use this app.)
|
||||
|------|----------|
|
||||
| [`src/RioJoy.Core`](src/RioJoy.Core/) | Protocol, profile model, input mapper, HID feeder (class library) |
|
||||
| [`src/RioJoy.Tray`](src/RioJoy.Tray/) | Background tray application |
|
||||
| [`tests/RioJoy.Core.Tests`](tests/RioJoy.Core.Tests/) | xUnit tests for the protocol core |
|
||||
| [`driver/`](driver/) | `RioGamepad` virtual HID driver (KMDF + VHF) — replaces vJoy |
|
||||
| [`docs/PLAN.md`](docs/PLAN.md) | Full modernization plan (7 phases) |
|
||||
| [`docs/PROTOCOL.md`](docs/PROTOCOL.md) | RIO wire format + `iRIO` input-map reference |
|
||||
@@ -29,8 +30,10 @@ Requires the **.NET 8 SDK** and Windows. The driver builds separately with the
|
||||
|
||||
```sh
|
||||
dotnet build RioJoy.sln -c Release
|
||||
dotnet test RioJoy.sln
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
Phase 0 (scaffold + plan). See [`docs/PLAN.md`](docs/PLAN.md) for what's next.
|
||||
Phase 2 (serial + RIO protocol core) is code-complete and unit-tested; hardware
|
||||
verification is pending. See [`docs/PLAN.md`](docs/PLAN.md) for the full roadmap.
|
||||
|
||||
@@ -9,6 +9,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RioJoy.Core", "src\RioJoy.C
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RioJoy.Tray", "src\RioJoy.Tray\RioJoy.Tray.csproj", "{FA2BAEAC-D8D5-47D9-B5EC-C9D10530D351}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{9FFCA6FA-1A95-4492-9A18-39D4F5720519}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RioJoy.Core.Tests", "tests\RioJoy.Core.Tests\RioJoy.Core.Tests.csproj", "{4A950510-432A-48C2-92E5-B87D075BA7DB}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -26,9 +30,14 @@ Global
|
||||
{FA2BAEAC-D8D5-47D9-B5EC-C9D10530D351}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{FA2BAEAC-D8D5-47D9-B5EC-C9D10530D351}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{FA2BAEAC-D8D5-47D9-B5EC-C9D10530D351}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{4A950510-432A-48C2-92E5-B87D075BA7DB}.Debug|Any CPU.ActiveCfg = Debug|x64
|
||||
{4A950510-432A-48C2-92E5-B87D075BA7DB}.Debug|Any CPU.Build.0 = Debug|x64
|
||||
{4A950510-432A-48C2-92E5-B87D075BA7DB}.Release|Any CPU.ActiveCfg = Release|x64
|
||||
{4A950510-432A-48C2-92E5-B87D075BA7DB}.Release|Any CPU.Build.0 = Release|x64
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{C81FEF57-A33B-4529-BDB7-02787407A545} = {CDDAA3FE-6A05-40A0-85CC-4DB0B3EEEA9C}
|
||||
{FA2BAEAC-D8D5-47D9-B5EC-C9D10530D351} = {CDDAA3FE-6A05-40A0-85CC-4DB0B3EEEA9C}
|
||||
{4A950510-432A-48C2-92E5-B87D075BA7DB} = {9FFCA6FA-1A95-4492-9A18-39D4F5720519}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
+15
-6
@@ -113,12 +113,21 @@ tray menu is always available.
|
||||
- Test harness (throwaway C#) wiggles axes/buttons; verify in `joy.cpl`.
|
||||
- Test-signing setup for the cabinets.
|
||||
|
||||
### Phase 2 — Serial + RIO protocol core (`RioJoy.Core`)
|
||||
- `SerialPort` wrapper; packet parser/builder (length table, 7-bit checksum,
|
||||
ACK/NAK, framing resync).
|
||||
- Analog poll timer + >5 s reset-recovery.
|
||||
- **Clean COM-port acquire/release** (foundation for serial yield).
|
||||
- Verify against hardware: version reply, check reply, analog stream.
|
||||
### Phase 2 — Serial + RIO protocol core (`RioJoy.Core`) — code-complete ✅
|
||||
Implemented in `src/RioJoy.Core/Protocol` + `Serial`, covered by
|
||||
`tests/RioJoy.Core.Tests` (xUnit, 54 tests):
|
||||
- Packet parser/builder: command/length table, 7-bit checksum, control chars,
|
||||
framing resync on a high-bit byte mid-packet (`PacketParser`, `PacketBuilder`).
|
||||
- Typed RIO→PC decodes: `AnalogReport` (14-bit sign-extend), `VersionInfo`,
|
||||
`CheckStatus`; lamp-state composition (`RioLampState`).
|
||||
- `RioSerialLink`: async receive loop with ACK/NAK policy (legacy force-accept
|
||||
vs. opt-in `VerifyInboundChecksum`), analog poll timer + >5 s reset-recovery.
|
||||
- `IRioTransport` abstraction with a `SerialPort`-backed implementation
|
||||
(9600 8N1, DTR reset pulse); **clean COM-port acquire/release** = create/dispose
|
||||
the transport (foundation for serial yield). The read loop is tested against an
|
||||
in-memory fake transport.
|
||||
- ⏳ **Remaining:** verify against real hardware (version reply, check reply,
|
||||
analog stream) — needs a cabinet; can't be done off-device.
|
||||
|
||||
### Phase 3 — Input mapping + output routing
|
||||
- Port `iRIO` decode and routing precedence (keyboard/mouse/joy/hat/RIO-command).
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using RioJoy.Core.Protocol;
|
||||
using Xunit;
|
||||
|
||||
namespace RioJoy.Core.Tests.Protocol;
|
||||
|
||||
public class AnalogReportTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(0x00, 0x00, 0)]
|
||||
[InlineData(0x7F, 0x00, 127)]
|
||||
[InlineData(0x7F, 0x3F, 8191)] // max positive 13-bit value
|
||||
[InlineData(0x00, 0x40, -8192)] // bit 13 set → most negative
|
||||
[InlineData(0x7F, 0x7F, -1)] // all 14 bits set → -1
|
||||
public void CombinePair_SignExtends14Bit(byte low, byte high, int expected)
|
||||
{
|
||||
Assert.Equal((short)expected, AnalogReport.CombinePair(low, high));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_DecodesAxesInOrder()
|
||||
{
|
||||
// throttle=1, left=2, right=3, Y=4, X=5 (each from a low byte, high=0)
|
||||
var payload = new byte[] { 1, 0, 2, 0, 3, 0, 4, 0, 5, 0 };
|
||||
|
||||
Assert.True(AnalogReport.TryParse(payload, out AnalogReport r));
|
||||
Assert.Equal(1, r.Throttle);
|
||||
Assert.Equal(2, r.LeftPedal);
|
||||
Assert.Equal(3, r.RightPedal);
|
||||
Assert.Equal(4, r.JoystickY);
|
||||
Assert.Equal(5, r.JoystickX);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_RejectsRestartSentinel()
|
||||
{
|
||||
// A 0xFE anywhere in the payload marks the sample invalid.
|
||||
var payload = new byte[] { 1, 0, 2, 0, 0xFE, 0, 4, 0, 5, 0 };
|
||||
Assert.False(AnalogReport.TryParse(payload, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_RejectsWrongLength()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => AnalogReport.TryParse(new byte[9], out _));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using RioJoy.Core.Protocol;
|
||||
using Xunit;
|
||||
|
||||
namespace RioJoy.Core.Tests.Protocol;
|
||||
|
||||
public class PacketBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
public void AnalogRequest_HasCommandAndChecksumOnly()
|
||||
{
|
||||
// 0x82, checksum = 0x82 & 0x7F = 0x02
|
||||
Assert.Equal(new byte[] { 0x82, 0x02 }, PacketBuilder.AnalogRequest());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetRequest_EncodesTarget()
|
||||
{
|
||||
// 0x83, payload [0x00], checksum = 0x03
|
||||
Assert.Equal(new byte[] { 0x83, 0x00, 0x03 }, PacketBuilder.ResetRequest(RioResetTarget.All));
|
||||
// 0x83, payload [0x01], checksum = 0x04
|
||||
Assert.Equal(new byte[] { 0x83, 0x01, 0x04 }, PacketBuilder.ResetRequest(RioResetTarget.Throttle));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LampRequest_EncodesLampAndState()
|
||||
{
|
||||
// 0x84, payload [0x05, 0x3C], checksum = (0x04 + 0x05 + 0x3C) & 0x7F = 0x45
|
||||
Assert.Equal(new byte[] { 0x84, 0x05, 0x3C, 0x45 }, PacketBuilder.LampRequest(5, 0x3C));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ValidatesPayloadLength()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => PacketBuilder.Build(RioCommand.LampRequest, new byte[] { 0x01 }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_AppendsCorrectChecksum_ForFullPayload()
|
||||
{
|
||||
var payload = new byte[] { 1, 0, 2, 0, 3, 0, 4, 0, 5, 0 };
|
||||
byte[] packet = PacketBuilder.Build(RioCommand.AnalogReply, payload);
|
||||
|
||||
Assert.Equal((byte)RioCommand.AnalogReply, packet[0]);
|
||||
Assert.Equal(payload, packet[1..^1]);
|
||||
Assert.Equal(RioChecksum.Compute(packet[..^1]), packet[^1]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using RioJoy.Core.Protocol;
|
||||
using Xunit;
|
||||
|
||||
namespace RioJoy.Core.Tests.Protocol;
|
||||
|
||||
public class PacketParserTests
|
||||
{
|
||||
private static List<RioRxEvent> FeedAll(PacketParser parser, ReadOnlySpan<byte> data)
|
||||
{
|
||||
var events = new List<RioRxEvent>();
|
||||
foreach (byte b in data)
|
||||
{
|
||||
if (parser.Feed(b, out RioRxEvent ev))
|
||||
events.Add(ev);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FramesValidPacket_WithChecksumValid()
|
||||
{
|
||||
var parser = new PacketParser();
|
||||
byte[] frame = PacketBuilder.Build(RioCommand.ButtonPressed, new byte[] { 0x05 });
|
||||
|
||||
List<RioRxEvent> events = FeedAll(parser, frame);
|
||||
|
||||
RioRxEvent ev = Assert.Single(events);
|
||||
Assert.Equal(RioRxEventKind.Packet, ev.Kind);
|
||||
Assert.True(ev.ChecksumValid);
|
||||
Assert.Equal(RioCommand.ButtonPressed, ev.Packet.Command);
|
||||
Assert.Equal(new byte[] { 0x05 }, ev.Packet.Payload.ToArray());
|
||||
Assert.False(parser.InPacket);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReportsChecksumInvalid_ButStillFramesPacket()
|
||||
{
|
||||
var parser = new PacketParser();
|
||||
byte[] frame = PacketBuilder.Build(RioCommand.ButtonPressed, new byte[] { 0x05 });
|
||||
frame[^1] ^= 0x01; // corrupt checksum (stays high-bit clear)
|
||||
|
||||
RioRxEvent ev = Assert.Single(FeedAll(parser, frame));
|
||||
Assert.Equal(RioRxEventKind.Packet, ev.Kind);
|
||||
Assert.False(ev.ChecksumValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ControlByte_OutsideFraming_IsReported()
|
||||
{
|
||||
var parser = new PacketParser();
|
||||
|
||||
RioRxEvent ev = Assert.Single(FeedAll(parser, new[] { (byte)RioControl.Ack }));
|
||||
Assert.Equal(RioRxEventKind.ControlByte, ev.Kind);
|
||||
Assert.True(ev.IsControl(RioControl.Ack));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HighBitMidPacket_AbortsAndResyncs()
|
||||
{
|
||||
var parser = new PacketParser();
|
||||
|
||||
// Start a ButtonPressed (expects 1 payload + 1 checksum), feed one payload
|
||||
// byte, then a high-bit byte mid-packet → framing error + resync.
|
||||
var events = new List<RioRxEvent>();
|
||||
foreach (byte b in new byte[] { 0x88, 0x05, 0x82 })
|
||||
{
|
||||
if (parser.Feed(b, out RioRxEvent ev))
|
||||
events.Add(ev);
|
||||
}
|
||||
|
||||
RioRxEvent framing = Assert.Single(events);
|
||||
Assert.Equal(RioRxEventKind.FramingError, framing.Kind);
|
||||
Assert.False(parser.InPacket);
|
||||
|
||||
// A subsequent clean frame parses normally (the parser resynced).
|
||||
byte[] next = PacketBuilder.Build(RioCommand.ButtonReleased, new byte[] { 0x07 });
|
||||
RioRxEvent ev2 = Assert.Single(FeedAll(parser, next));
|
||||
Assert.Equal(RioCommand.ButtonReleased, ev2.Packet.Command);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParsesMultiplePackets_InOneStream()
|
||||
{
|
||||
var parser = new PacketParser();
|
||||
byte[] a = PacketBuilder.Build(RioCommand.ButtonPressed, new byte[] { 0x01 });
|
||||
byte[] b = PacketBuilder.Build(RioCommand.ButtonReleased, new byte[] { 0x02 });
|
||||
byte[] stream = new byte[a.Length + b.Length];
|
||||
a.CopyTo(stream, 0);
|
||||
b.CopyTo(stream, a.Length);
|
||||
|
||||
List<RioRxEvent> events = FeedAll(parser, stream);
|
||||
|
||||
Assert.Equal(2, events.Count);
|
||||
Assert.Equal(RioCommand.ButtonPressed, events[0].Packet.Command);
|
||||
Assert.Equal(RioCommand.ButtonReleased, events[1].Packet.Command);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandlesPacket_SplitAcrossFeeds()
|
||||
{
|
||||
var parser = new PacketParser();
|
||||
byte[] frame = PacketBuilder.Build(RioCommand.AnalogReply, new byte[] { 1, 0, 2, 0, 3, 0, 4, 0, 5, 0 });
|
||||
|
||||
// Feed byte-by-byte: only the final byte should yield the packet event.
|
||||
var events = new List<RioRxEvent>();
|
||||
for (int i = 0; i < frame.Length; i++)
|
||||
{
|
||||
bool produced = parser.Feed(frame[i], out RioRxEvent ev);
|
||||
if (produced)
|
||||
events.Add(ev);
|
||||
|
||||
Assert.Equal(i == frame.Length - 1, produced);
|
||||
}
|
||||
|
||||
RioRxEvent done = Assert.Single(events);
|
||||
Assert.Equal(RioCommand.AnalogReply, done.Packet.Command);
|
||||
Assert.True(done.ChecksumValid);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using RioJoy.Core.Protocol;
|
||||
using Xunit;
|
||||
|
||||
namespace RioJoy.Core.Tests.Protocol;
|
||||
|
||||
public class RioChecksumTests
|
||||
{
|
||||
[Fact]
|
||||
public void Empty_IsZero()
|
||||
{
|
||||
Assert.Equal(0, RioChecksum.Compute(ReadOnlySpan<byte>.Empty));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SumsLow7Bits()
|
||||
{
|
||||
// (0x84 & 0x7F) + (0x05 & 0x7F) + (0x3C & 0x7F) = 0x04 + 0x05 + 0x3C = 0x45
|
||||
byte sum = RioChecksum.Compute(new byte[] { 0x84, 0x05, 0x3C });
|
||||
Assert.Equal(0x45, sum);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MasksResultTo7Bits()
|
||||
{
|
||||
// Sum of low-7-bits = 0x7F + 0x7F = 0xFE; masked to 7 bits → 0x7E.
|
||||
byte sum = RioChecksum.Compute(new byte[] { 0x7F, 0x7F });
|
||||
Assert.Equal(0x7E, sum);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IgnoresHighBitOfInputs()
|
||||
{
|
||||
// 0x82 contributes only 0x02.
|
||||
Assert.Equal(0x02, RioChecksum.Compute(new byte[] { 0x82 }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using RioJoy.Core.Protocol;
|
||||
using Xunit;
|
||||
|
||||
namespace RioJoy.Core.Tests.Protocol;
|
||||
|
||||
public class RioCommandTableTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(RioCommand.CheckRequest, 0)]
|
||||
[InlineData(RioCommand.VersionRequest, 0)]
|
||||
[InlineData(RioCommand.AnalogRequest, 0)]
|
||||
[InlineData(RioCommand.ResetRequest, 1)]
|
||||
[InlineData(RioCommand.LampRequest, 2)]
|
||||
[InlineData(RioCommand.CheckReply, 2)]
|
||||
[InlineData(RioCommand.VersionReply, 2)]
|
||||
[InlineData(RioCommand.AnalogReply, 10)]
|
||||
[InlineData(RioCommand.ButtonPressed, 1)]
|
||||
[InlineData(RioCommand.ButtonReleased, 1)]
|
||||
[InlineData(RioCommand.KeyPressed, 2)]
|
||||
[InlineData(RioCommand.KeyReleased, 2)]
|
||||
[InlineData(RioCommand.TestModeChange, 1)]
|
||||
public void PayloadLengths_MatchProtocol(RioCommand command, int expected)
|
||||
{
|
||||
Assert.Equal(expected, RioCommandTable.PayloadLength(command));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bounds_AreInclusive()
|
||||
{
|
||||
Assert.Equal(0x80, RioCommandTable.Base);
|
||||
Assert.Equal(0x8C, RioCommandTable.Max);
|
||||
Assert.Equal(13, RioCommandTable.Count);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x7F, false)]
|
||||
[InlineData(0x80, true)]
|
||||
[InlineData(0x8C, true)]
|
||||
[InlineData(0x8D, false)]
|
||||
[InlineData(0xFC, false)]
|
||||
public void IsCommand_RespectsRange(byte b, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, RioCommandTable.IsCommand(b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PayloadLength_ThrowsForNonCommand()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => RioCommandTable.PayloadLength((byte)0x8D));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using RioJoy.Core.Protocol;
|
||||
using Xunit;
|
||||
|
||||
namespace RioJoy.Core.Tests.Protocol;
|
||||
|
||||
public class RioLampStateTests
|
||||
{
|
||||
[Fact]
|
||||
public void CommonCombos_MatchLegacyValues()
|
||||
{
|
||||
Assert.Equal(0x00, RioLampState.SolidOff);
|
||||
Assert.Equal(0x14, RioLampState.SolidDim);
|
||||
Assert.Equal(0x3C, RioLampState.SolidBright);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compose_AddsFlashAndBrightnessFields()
|
||||
{
|
||||
// flashMed(2) + field1 Dim(0x04) + field2 Bright(0x30) = 0x36
|
||||
byte state = RioLampState.Compose(LampFlash.FlashMed, LampField1.Dim, LampField2.Bright);
|
||||
Assert.Equal(0x36, state);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using RioJoy.Core.Protocol;
|
||||
using Xunit;
|
||||
|
||||
namespace RioJoy.Core.Tests.Protocol;
|
||||
|
||||
public class RioRepliesTests
|
||||
{
|
||||
[Fact]
|
||||
public void VersionInfo_ParsesMajorMinor()
|
||||
{
|
||||
VersionInfo v = VersionInfo.Parse(new byte[] { 0, 3 });
|
||||
Assert.Equal(0, v.Major);
|
||||
Assert.Equal(3, v.Minor);
|
||||
Assert.Equal("0.3", v.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckStatus_ParsesTypeAndNumber()
|
||||
{
|
||||
CheckStatus s = CheckStatus.Parse(new byte[] { (byte)RioStatusType.BoardMissing, 7 });
|
||||
Assert.Equal(RioStatusType.BoardMissing, s.Type);
|
||||
Assert.Equal(7, s.Number);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_RejectsWrongLength()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => VersionInfo.Parse(new byte[] { 1 }));
|
||||
Assert.Throws<ArgumentException>(() => CheckStatus.Parse(new byte[] { 1, 2, 3 }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<Platforms>x64</Platforms>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\RioJoy.Core\RioJoy.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Threading.Channels;
|
||||
using RioJoy.Core.Serial;
|
||||
|
||||
namespace RioJoy.Core.Tests.Serial;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory <see cref="IRioTransport"/> for driving <see cref="RioSerialLink"/>
|
||||
/// in tests: enqueue inbound chunks, inspect outbound writes.
|
||||
/// </summary>
|
||||
internal sealed class FakeTransport : IRioTransport
|
||||
{
|
||||
private readonly Channel<byte[]> _incoming = Channel.CreateUnbounded<byte[]>();
|
||||
private readonly Channel<byte[]> _writes = Channel.CreateUnbounded<byte[]>();
|
||||
|
||||
public string Description => "fake";
|
||||
|
||||
/// <summary>Outbound writes, in order. Each call to WriteAsync yields one item.</summary>
|
||||
public ChannelReader<byte[]> Writes => _writes.Reader;
|
||||
|
||||
/// <summary>Queue an inbound chunk for the receive loop to read.</summary>
|
||||
public void Enqueue(params byte[] data) => _incoming.Writer.TryWrite(data);
|
||||
|
||||
/// <summary>Signal that no more inbound data will arrive (transport closed).</summary>
|
||||
public void CompleteIncoming() => _incoming.Writer.TryComplete();
|
||||
|
||||
public async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken)
|
||||
{
|
||||
while (await _incoming.Reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (_incoming.Reader.TryRead(out byte[]? chunk))
|
||||
{
|
||||
chunk.AsSpan().CopyTo(buffer.Span);
|
||||
return chunk.Length;
|
||||
}
|
||||
}
|
||||
|
||||
return 0; // completed
|
||||
}
|
||||
|
||||
public ValueTask WriteAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken)
|
||||
{
|
||||
_writes.Writer.TryWrite(data.ToArray());
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Read the next outbound write, failing if none arrives in time.</summary>
|
||||
public async Task<byte[]> NextWriteAsync(TimeSpan? timeout = null)
|
||||
{
|
||||
using var cts = new CancellationTokenSource(timeout ?? TimeSpan.FromSeconds(5));
|
||||
return await _writes.Reader.ReadAsync(cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
using RioJoy.Core.Protocol;
|
||||
using RioJoy.Core.Serial;
|
||||
using Xunit;
|
||||
|
||||
namespace RioJoy.Core.Tests.Serial;
|
||||
|
||||
public class RioSerialLinkTests
|
||||
{
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
[Fact]
|
||||
public async Task AnalogReply_DecodesAndAcks()
|
||||
{
|
||||
var fake = new FakeTransport();
|
||||
var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false });
|
||||
|
||||
var gotAnalog = new TaskCompletionSource<AnalogReport>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
link.AnalogReceived += r => gotAnalog.TrySetResult(r);
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
Task run = link.RunAsync(cts.Token);
|
||||
|
||||
byte[] frame = PacketBuilder.Build(RioCommand.AnalogReply, new byte[] { 1, 0, 2, 0, 3, 0, 4, 0, 5, 0 });
|
||||
fake.Enqueue(frame);
|
||||
|
||||
AnalogReport report = await gotAnalog.Task.WaitAsync(Timeout);
|
||||
Assert.Equal(1, report.Throttle);
|
||||
Assert.Equal(5, report.JoystickX);
|
||||
|
||||
// The link should have ACK'd the packet.
|
||||
byte[] reply = await fake.NextWriteAsync();
|
||||
Assert.Equal(new byte[] { (byte)RioControl.Ack }, reply);
|
||||
|
||||
cts.Cancel();
|
||||
await run;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BadChecksumButton_IsNakd_WhenVerificationEnabled()
|
||||
{
|
||||
var fake = new FakeTransport();
|
||||
var link = new RioSerialLink(fake, new RioSerialLinkOptions
|
||||
{
|
||||
AutoPollAnalog = false,
|
||||
VerifyInboundChecksum = true,
|
||||
});
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
Task run = link.RunAsync(cts.Token);
|
||||
|
||||
byte[] frame = PacketBuilder.Build(RioCommand.ButtonPressed, new byte[] { 0x05 });
|
||||
frame[^1] ^= 0x01; // corrupt checksum
|
||||
fake.Enqueue(frame);
|
||||
|
||||
byte[] reply = await fake.NextWriteAsync();
|
||||
Assert.Equal(new byte[] { (byte)RioControl.Nak }, reply);
|
||||
|
||||
cts.Cancel();
|
||||
await run;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BadChecksumButton_IsAckd_WhenVerificationDisabled()
|
||||
{
|
||||
var fake = new FakeTransport();
|
||||
var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false });
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
Task run = link.RunAsync(cts.Token);
|
||||
|
||||
byte[] frame = PacketBuilder.Build(RioCommand.ButtonPressed, new byte[] { 0x05 });
|
||||
frame[^1] ^= 0x01;
|
||||
fake.Enqueue(frame);
|
||||
|
||||
byte[] reply = await fake.NextWriteAsync();
|
||||
Assert.Equal(new byte[] { (byte)RioControl.Ack }, reply);
|
||||
|
||||
cts.Cancel();
|
||||
await run;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VersionReply_DecodesAndRaisesEvent()
|
||||
{
|
||||
var fake = new FakeTransport();
|
||||
var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false });
|
||||
|
||||
var gotVersion = new TaskCompletionSource<VersionInfo>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
link.VersionReceived += v => gotVersion.TrySetResult(v);
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
Task run = link.RunAsync(cts.Token);
|
||||
|
||||
fake.Enqueue(PacketBuilder.Build(RioCommand.VersionReply, new byte[] { 0, 3 }));
|
||||
|
||||
VersionInfo version = await gotVersion.Task.WaitAsync(Timeout);
|
||||
Assert.Equal(0, version.Major);
|
||||
Assert.Equal(3, version.Minor);
|
||||
|
||||
cts.Cancel();
|
||||
await run;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ControlByte_RaisesControlReceived()
|
||||
{
|
||||
var fake = new FakeTransport();
|
||||
var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false });
|
||||
|
||||
var gotControl = new TaskCompletionSource<byte>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
link.ControlReceived += b => gotControl.TrySetResult(b);
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
Task run = link.RunAsync(cts.Token);
|
||||
|
||||
fake.Enqueue((byte)RioControl.Ack);
|
||||
|
||||
byte control = await gotControl.Task.WaitAsync(Timeout);
|
||||
Assert.Equal((byte)RioControl.Ack, control);
|
||||
|
||||
cts.Cancel();
|
||||
await run;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AutoPoll_SendsAnalogRequest()
|
||||
{
|
||||
var fake = new FakeTransport();
|
||||
var link = new RioSerialLink(fake, new RioSerialLinkOptions
|
||||
{
|
||||
AutoPollAnalog = true,
|
||||
AnalogPollInterval = TimeSpan.FromMilliseconds(20),
|
||||
});
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
Task run = link.RunAsync(cts.Token);
|
||||
|
||||
byte[] firstWrite = await fake.NextWriteAsync();
|
||||
Assert.Equal(PacketBuilder.AnalogRequest(), firstWrite);
|
||||
|
||||
cts.Cancel();
|
||||
await run;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user