The board ACK/NAKs every inbound packet; we were fire-and-forget, so any corrupt arrival became a permanent state error (stuck-bright / missed lamps at 62500, where ~6.5% of 4-byte lamp commands lose bytes to RX-ISR overrun). Now a NAK control byte triggers a resend of the most recent command packet, bounded by NakRetransmitLimit (default 2; 0 restores fire-and-forget). Design notes: the wire has no sequence numbers, but every PC->RIO command is idempotent (lamp state, analog request, reset), so resending the latest command is safe even in the rare race where the NAK belonged to an earlier packet. Single control-byte replies (our ACKs) never participate. NakRetransmits counter surfaced in the mash summary. 6 new tests (retransmit, limit, budget reset, ACK no-op, control-byte exclusion, disable switch); 281 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
301 lines
9.6 KiB
C#
301 lines
9.6 KiB
C#
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;
|
|
}
|
|
|
|
// --- NAK retransmit (RioSerialLinkOptions.NakRetransmitLimit) ------------
|
|
|
|
[Fact]
|
|
public async Task Nak_RetransmitsLastCommand()
|
|
{
|
|
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[] lamp = PacketBuilder.LampRequest(0x05, 0x02);
|
|
await link.SendAsync(lamp);
|
|
Assert.Equal(lamp, await fake.NextWriteAsync());
|
|
|
|
fake.Enqueue((byte)RioControl.Nak); // board: "that arrived corrupt"
|
|
|
|
Assert.Equal(lamp, await fake.NextWriteAsync()); // resent
|
|
Assert.Equal(1, link.NakRetransmits);
|
|
|
|
cts.Cancel();
|
|
await run;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Nak_RetransmitStopsAtLimit()
|
|
{
|
|
var fake = new FakeTransport();
|
|
var link = new RioSerialLink(fake, new RioSerialLinkOptions
|
|
{
|
|
AutoPollAnalog = false,
|
|
NakRetransmitLimit = 2,
|
|
});
|
|
|
|
using var cts = new CancellationTokenSource();
|
|
Task run = link.RunAsync(cts.Token);
|
|
|
|
byte[] lamp = PacketBuilder.LampRequest(0x05, 0x02);
|
|
await link.SendAsync(lamp);
|
|
await fake.NextWriteAsync(); // original
|
|
|
|
for (int i = 0; i < 4; i++)
|
|
fake.Enqueue((byte)RioControl.Nak);
|
|
|
|
Assert.Equal(lamp, await fake.NextWriteAsync()); // retry 1
|
|
Assert.Equal(lamp, await fake.NextWriteAsync()); // retry 2
|
|
|
|
// Budget spent: NAKs 3 and 4 must produce no further writes.
|
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
|
() => fake.NextWriteAsync(TimeSpan.FromMilliseconds(200)));
|
|
Assert.Equal(2, link.NakRetransmits);
|
|
|
|
cts.Cancel();
|
|
await run;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Ack_DoesNotRetransmit()
|
|
{
|
|
var fake = new FakeTransport();
|
|
var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false });
|
|
|
|
using var cts = new CancellationTokenSource();
|
|
Task run = link.RunAsync(cts.Token);
|
|
|
|
await link.SendAsync(PacketBuilder.LampRequest(0x05, 0x02));
|
|
await fake.NextWriteAsync();
|
|
|
|
fake.Enqueue((byte)RioControl.Ack);
|
|
|
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
|
() => fake.NextWriteAsync(TimeSpan.FromMilliseconds(200)));
|
|
Assert.Equal(0, link.NakRetransmits);
|
|
|
|
cts.Cancel();
|
|
await run;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NewCommand_ResetsRetryBudget()
|
|
{
|
|
var fake = new FakeTransport();
|
|
var link = new RioSerialLink(fake, new RioSerialLinkOptions
|
|
{
|
|
AutoPollAnalog = false,
|
|
NakRetransmitLimit = 1,
|
|
});
|
|
|
|
using var cts = new CancellationTokenSource();
|
|
Task run = link.RunAsync(cts.Token);
|
|
|
|
byte[] first = PacketBuilder.LampRequest(0x05, 0x02);
|
|
await link.SendAsync(first);
|
|
await fake.NextWriteAsync();
|
|
fake.Enqueue((byte)RioControl.Nak);
|
|
Assert.Equal(first, await fake.NextWriteAsync()); // budget of 'first' spent
|
|
|
|
byte[] second = PacketBuilder.LampRequest(0x06, 0x01);
|
|
await link.SendAsync(second);
|
|
Assert.Equal(second, await fake.NextWriteAsync());
|
|
fake.Enqueue((byte)RioControl.Nak);
|
|
Assert.Equal(second, await fake.NextWriteAsync()); // fresh budget applies
|
|
|
|
cts.Cancel();
|
|
await run;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OwnControlByteReplies_AreNeverRetransmitted()
|
|
{
|
|
var fake = new FakeTransport();
|
|
var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false });
|
|
|
|
using var cts = new CancellationTokenSource();
|
|
Task run = link.RunAsync(cts.Token);
|
|
|
|
// Inbound button packet -> the link replies with a 1-byte ACK. That ACK
|
|
// must not become "the last command" for retransmit purposes.
|
|
fake.Enqueue(PacketBuilder.Build(RioCommand.ButtonPressed, new byte[] { 0x05 }));
|
|
Assert.Equal(new byte[] { (byte)RioControl.Ack }, await fake.NextWriteAsync());
|
|
|
|
fake.Enqueue((byte)RioControl.Nak);
|
|
|
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
|
() => fake.NextWriteAsync(TimeSpan.FromMilliseconds(200)));
|
|
Assert.Equal(0, link.NakRetransmits);
|
|
|
|
cts.Cancel();
|
|
await run;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Retransmit_Disabled_IsFireAndForget()
|
|
{
|
|
var fake = new FakeTransport();
|
|
var link = new RioSerialLink(fake, new RioSerialLinkOptions
|
|
{
|
|
AutoPollAnalog = false,
|
|
NakRetransmitLimit = 0,
|
|
});
|
|
|
|
using var cts = new CancellationTokenSource();
|
|
Task run = link.RunAsync(cts.Token);
|
|
|
|
await link.SendAsync(PacketBuilder.LampRequest(0x05, 0x02));
|
|
await fake.NextWriteAsync();
|
|
fake.Enqueue((byte)RioControl.Nak);
|
|
|
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
|
() => fake.NextWriteAsync(TimeSpan.FromMilliseconds(200)));
|
|
Assert.Equal(0, link.NakRetransmits);
|
|
|
|
cts.Cancel();
|
|
await run;
|
|
}
|
|
}
|