RioSerialMonitor: --e0test mode — on-hardware E0-threshold verification

Two-phase test against a DTR-reset board: phase A forces sub-threshold
retransmits (NAK the completed reply frame -> exactly one counted
retransmit, then ACK the retry), where the display must hold F0000000;
phase B withholds ACKs for a full give-up cycle to cross the threshold,
with the expected E0 readout predicted from observed traffic.

Bench findings while building it (9600, e0t5 chip): the reply-retry
machine sends 5 retransmits per cycle (not 4); responses are honored
only after the complete reply frame (mid-frame ACK/NAK/RESTART is
ignored); once the first retry fires the cycle runs blind to give-up;
a host NAK triggers an immediate counted retransmit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-19 15:32:21 -05:00
co-authored by Claude Fable 5
parent 2e727a4c1c
commit 6b02eb4d1b
2 changed files with 278 additions and 0 deletions
+273
View File
@@ -0,0 +1,273 @@
using System.Diagnostics;
using RioJoy.Core.Serial;
namespace RioSerialMonitor;
/// <summary>
/// E0-threshold firmware verification (rio-firmware `--e0thresh` images).
///
/// Two phases against a DTR-reset board (counters zeroed, display F0000000):
/// - Phase A (gate hold): analog requests whose reply we ACK only after the
/// first retransmit — each event increments the board's retransmit
/// counter ($3184) by a small sub-threshold amount, calling the display
/// renderer every time. The _e0t5 gate must keep the display F0000000;
/// stock firmware paints E0... at the first retry.
/// - Phase B (flip): analog requests we never ACK — the board runs the full
/// retry cycle (observed: 5 retransmits) and gives up ($3185++, RESTART
/// $FE). The counters cross the threshold and the display must flip to
/// the E0 readout, which the tool predicts from observed traffic.
///
/// dotnet run --project tools/RioSerialMonitor -- --e0test [port]
/// [--baud rate] [--hold n] [--flip n]
///
/// No --baud probes 9600, 31250, 62500 (DTR-resetting each try). HANDS OFF
/// buttons/keypads during the run — an unACKed button packet shifts the
/// counters (the tool folds observed traffic into its predictions either
/// way). Exit: 0 = ran, 2 = no board reply on any baud.
/// </summary>
internal static class E0Test
{
private const int Threshold = 5;
private enum AckMode { Never, Immediate, NakThenAck }
public static async Task<int> RunAsync(string[] args)
{
string port = "COM1";
int baud = 0, holdEvents = 4, flipEvents = 1;
for (int i = 0; i < args.Length; i++)
{
switch (args[i])
{
case "--e0test": break;
case "--baud" when i + 1 < args.Length && int.TryParse(args[i + 1], out int b): baud = b; i++; break;
case "--hold" when i + 1 < args.Length && int.TryParse(args[i + 1], out int h) && h >= 0: holdEvents = h; i++; break;
case "--flip" when i + 1 < args.Length && int.TryParse(args[i + 1], out int f) && f > 0: flipEvents = f; i++; break;
default:
if (!args[i].StartsWith("--")) port = args[i];
break;
}
}
var sw = Stopwatch.StartNew();
object gate = new();
var buffer = new List<byte>();
int cursor = 0;
// Reader-thread ACK automation (latency matters: the board's ACK wait
// at 9600 is ~4ms/retry, so ACKs must not wait for a polling loop).
AckMode ackMode = AckMode.Never;
int replyByte = 0x87; // frame command byte the current phase expects
int frameLen = 12; // full frame: cmd + payload + checksum
int repliesSeen = 0; // completed frames of replyByte since phase reset
int frameCountdown = 0; // >0: inside a reply frame, bytes remaining
bool ackSent = false;
SerialPortTransport? transport = null;
Task? reader = null;
void PhaseReset(AckMode mode, int expectReply, int expectLen)
{
lock (gate)
{
ackMode = mode;
replyByte = expectReply;
frameLen = expectLen;
repliesSeen = 0;
frameCountdown = 0;
ackSent = false;
cursor = buffer.Count;
}
}
int[] bauds = baud != 0 ? new[] { baud } : new[] { 9600, 31250, 62500 };
(int Major, int Minor)? version = null;
foreach (int tryBaud in bauds)
{
Console.WriteLine($"[{sw.Elapsed.TotalSeconds,6:F2}s] probing {port} @ {tryBaud} (DTR reset, ~2s boot wait)...");
try { transport = new SerialPortTransport(port, tryBaud); }
catch (Exception ex)
{
Console.WriteLine($" FAILED to open {port}: {ex.GetType().Name}: {ex.Message}");
return 2;
}
lock (gate) { buffer.Clear(); cursor = 0; }
SerialPortTransport t = transport;
reader = Task.Run(async () =>
{
var tmp = new byte[256];
try
{
while (true)
{
int n = await t.ReadAsync(tmp, CancellationToken.None);
if (n <= 0) break;
byte? respond = null;
lock (gate)
{
for (int i = 0; i < n; i++)
{
buffer.Add(tmp[i]);
// Frame tracking: respond only once the FULL reply
// frame is on the wire — the board arms its
// reply-await when the frame finishes sending; a
// response mid-frame is ignored.
if (frameCountdown > 0)
{
if (--frameCountdown > 0) continue;
repliesSeen++;
if (ackMode == AckMode.Immediate && !ackSent)
{
ackSent = true;
respond = 0xFC;
}
else if (ackMode == AckMode.NakThenAck && repliesSeen <= 2)
{
// frame 1 -> NAK (board resends, $3184++),
// frame 2 -> ACK (cycle ends cleanly).
respond = repliesSeen == 1 ? (byte)0xFD : (byte)0xFC;
ackSent = repliesSeen == 2;
}
}
else if (tmp[i] == replyByte)
{
frameCountdown = frameLen - 1;
}
}
}
if (respond is byte r)
await t.WriteAsync(new byte[] { r }, CancellationToken.None);
}
}
catch { /* port closed */ }
});
await Task.Delay(2000); // boot: counters cleared, display F0000000
PhaseReset(AckMode.Immediate, 0x86, 4); // version reply: cmd+2+ck
await t.WriteAsync(new byte[] { 0x81, 0x01 }, CancellationToken.None);
byte[] got = await Collect(1200, b => IndexOf(b, 0x86) >= 0);
int vi = IndexOf(got, 0x86);
if (vi >= 0)
{
if (vi + 2 < got.Length) version = (got[vi + 1], got[vi + 2]);
baud = tryBaud;
break;
}
Console.WriteLine($" no version reply at {tryBaud}");
transport.Dispose();
transport = null;
if (reader is not null) await reader;
}
if (transport is null)
{
Console.WriteLine(" no board reply on any baud — is the board powered / the right chip in?");
return 2;
}
// Handshake bookkeeping: fold any retries/give-up into the prediction.
await Task.Delay(300);
byte[] hs = Drain();
int retx = Math.Max(0, CountAll(0x86) - 1); // $3184 prediction
int giveup = SeenAll(0xFE) ? 1 : 0; // $3185 prediction
Console.WriteLine();
Console.WriteLine($"== E0-threshold test :: {transport.Description}, firmware {version?.Major}.{version?.Minor} ==");
Console.WriteLine($" threshold {Threshold}; phase A: {holdEvents} sub-threshold event(s), phase B: {flipEvents} give-up event(s)");
if (retx > 0 || giveup > 0)
Console.WriteLine($" note: handshake cost {retx} retransmit(s){(giveup > 0 ? " + a give-up" : "")}; predictions include them.");
Console.WriteLine(" HANDS OFF buttons/keypads for the whole run.");
Console.WriteLine();
string expected = Expected(retx, giveup);
Console.WriteLine($">>> WATCH THE 8-DIGIT DISPLAY. It should read {expected} right now. <<<");
await Task.Delay(4000);
int eventNo = 0;
bool ok = true;
for (int k = 0; k < holdEvents && ok; k++)
ok = await RunEvent(AckMode.NakThenAck, "hold");
for (int k = 0; k < flipEvents && ok; k++)
ok = await RunEvent(AckMode.Never, "flip");
Console.WriteLine();
Console.WriteLine("== done ==");
Console.WriteLine($" final predicted counters: retx=${retx:X2} giveup=${giveup:X2} -> display {Expected(retx, giveup)}");
Console.WriteLine(" PASS = display held F0000000 through every sub-threshold line above and");
Console.WriteLine(" matched the E0 predictions after the threshold crossing.");
Console.WriteLine(" (Stock firmware flips at the very first retry.) DTR reset restores F0.");
transport.Dispose();
if (reader is not null) await reader;
return 0;
async Task<bool> RunEvent(AckMode mode, string phase)
{
eventNo++;
PhaseReset(mode, 0x87, 12); // analog reply: cmd+10+ck
await transport!.WriteAsync(new byte[] { 0x82, 0x02 }, CancellationToken.None);
byte[] bytes = mode == AckMode.Never
? await Collect(2500, b => IndexOf(b, 0xFE) >= 0)
: await Collect(800, _ => false);
await Task.Delay(250); // let the cycle finish either way
bytes = Concat(bytes, Drain());
int transmissions = Count(bytes, 0x87);
bool sawRestart = IndexOf(bytes, 0xFE) >= 0;
if (transmissions == 0)
{
Console.WriteLine($"[event {eventNo}] NO analog reply ({bytes.Length} bytes) — aborting.");
return false;
}
retx += Math.Max(0, transmissions - 1);
if (sawRestart) giveup++;
Console.WriteLine($"[event {eventNo}] {phase}: reply x{transmissions} " +
$"({transmissions - 1} retries), give-up={sawRestart} " +
$"-> retx=${retx:X2} giveup=${giveup:X2}");
Console.WriteLine($" >>> DISPLAY SHOULD NOW READ: {Expected(retx, giveup)} <<<");
await Task.Delay(4000);
return true;
}
string Expected(int r, int g) =>
r >= Threshold || g >= Threshold ? $"E000{r:X2}{g:X2}" : "F0000000";
byte[] Drain()
{
lock (gate)
{
byte[] r = buffer.Skip(cursor).ToArray();
cursor = buffer.Count;
return r;
}
}
int CountAll(byte v) { lock (gate) return buffer.Count(x => x == v); }
bool SeenAll(byte v) { lock (gate) return buffer.Contains(v); }
async Task<byte[]> Collect(int ms, Func<byte[], bool> done)
{
var end = sw.Elapsed + TimeSpan.FromMilliseconds(ms);
var all = new List<byte>();
while (sw.Elapsed < end)
{
all.AddRange(Drain());
if (done(all.ToArray())) break;
await Task.Delay(15);
}
all.AddRange(Drain());
return all.ToArray();
}
static int IndexOf(byte[] a, byte v) => Array.IndexOf(a, v);
static int Count(byte[] a, byte v) => a.Count(x => x == v);
static byte[] Concat(byte[] a, byte[] b) => a.Concat(b).ToArray();
}
}
+5
View File
@@ -15,6 +15,11 @@ using RioJoy.Core.Serial;
// --baud: 31250 for the retuned firmware variant (rio-firmware --baud31250). // --baud: 31250 for the retuned firmware variant (rio-firmware --baud31250).
// Exit: 0 = ran, 2 = could not open the port (mash: 1 = wedge detected). // Exit: 0 = ran, 2 = could not open the port (mash: 1 = wedge detected).
// E0-threshold firmware verification (rio-firmware --e0thresh images, see E0Test.cs):
// dotnet run --project tools/RioSerialMonitor -- --e0test [port] [--baud rate] [--events n]
if (args.Contains("--e0test"))
return await RioSerialMonitor.E0Test.RunAsync(args);
if (args.Contains("--mash")) if (args.Contains("--mash"))
return await RioSerialMonitor.MashTest.RunAsync(args); return await RioSerialMonitor.MashTest.RunAsync(args);