Firmware validation harness: instrumented RIO_TAP mash test in RioSerialMonitor
New --mash mode (tools/RioSerialMonitor/MashTest.cs) mechanizes the wedge-patch validation plan from RIOv4_2-ANALYSIS.md: - Runs the live link with the app's >5s reset-recovery DISABLED so a board wedge stays observable, and echoes lamps on every press (lamp/reply collisions are the wedge trigger). - Gap timing uses ANY AnalogReply packet (0xFE sentinels included - a sentinel still proves the reply path is alive); logs a gap histogram + top-10 longest gaps with timestamps. - WEDGE detector: analog silent past the threshold (default 2s) -> beep + banner; on resume, classifies self-recovered (patched expectation) vs button-revived (button event within 300ms of resume, the unpatched signature) vs unresolved at run end. - Board self-reported RestartCount/AbandonCount/FullBufferCount snapshotted before/after via CheckRequest, delta printed (7-bit wrap-aware). - Fixed-layout summary teed to riomash-<label>-<stamp>.log so baseline-vs-patched runs diff directly. Exit 0 = no wedge, 1 = wedge. --mash --selftest drives the whole instrument against a scripted in-memory board (SelftestTransport) that goes silent at t=4.0s and revives 200ms after a button at t=6.5s: verified end-to-end - alarm at 6.0s, wedge classified button-revived (2.75s), counter delta +4/+0/+1, verdict FAIL, exit 1. Use it to sanity-check the alarm at the cabinet. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using RioJoy.Core.Protocol;
|
||||
using RioJoy.Core.Serial;
|
||||
|
||||
namespace RioSerialMonitor;
|
||||
|
||||
/// <summary>
|
||||
/// Scripted in-memory RIO for <c>--mash --selftest</c>: proves the mash-test
|
||||
/// instrumentation (wedge alarm, revival classification, counter delta) works
|
||||
/// before it is trusted to judge firmware at the cabinet.
|
||||
///
|
||||
/// Timeline (seconds from open):
|
||||
/// 0.0 – 4.0 analog replies every 50 ms (healthy)
|
||||
/// 4.0 – 6.7 analog SILENT (the wedge; alarm must fire at ~6.0 with the
|
||||
/// default 2 s threshold) while the port stays open
|
||||
/// 6.5 / 7.0 ButtonPressed / ButtonReleased 0x05 (masher still mashing)
|
||||
/// 6.7 – end analog resumes 200 ms after the button — the tool must
|
||||
/// classify the wedge as "button-revived" (unpatched signature)
|
||||
/// CheckRequest is answered with RestartCount 3 / FullBufferCount 1 on the
|
||||
/// first ask and 7 / 2 afterwards, so the counter delta must read +4 / +1.
|
||||
/// Expected run outcome: 1 wedge event, verdict FAIL (button-revived), exit 1.
|
||||
/// </summary>
|
||||
internal sealed class SelftestTransport : IRioTransport
|
||||
{
|
||||
private readonly ConcurrentQueue<byte[]> _rx = new();
|
||||
private readonly SemaphoreSlim _rxReady = new(0);
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private readonly Stopwatch _clock = Stopwatch.StartNew();
|
||||
private int _checkCalls;
|
||||
|
||||
public SelftestTransport() => _ = ProduceAsync(_cts.Token);
|
||||
|
||||
public string Description => "selftest (scripted in-memory RIO)";
|
||||
|
||||
public async Task<int> ReadAsync(byte[] buffer, CancellationToken cancellationToken)
|
||||
{
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _cts.Token);
|
||||
await _rxReady.WaitAsync(linked.Token).ConfigureAwait(false);
|
||||
if (!_rx.TryDequeue(out byte[]? chunk))
|
||||
return 0;
|
||||
Array.Copy(chunk, buffer, chunk.Length);
|
||||
return chunk.Length;
|
||||
}
|
||||
|
||||
public Task WriteAsync(byte[] data, CancellationToken cancellationToken)
|
||||
{
|
||||
// Answer the requests the test sends; swallow analog polls + lamp writes.
|
||||
if (data.Length > 0)
|
||||
{
|
||||
if (data[0] == (byte)RioCommand.VersionRequest)
|
||||
{
|
||||
Enqueue(PacketBuilder.Build(RioCommand.VersionReply, new byte[] { 4, 2 }));
|
||||
}
|
||||
else if (data[0] == (byte)RioCommand.CheckRequest)
|
||||
{
|
||||
bool first = Interlocked.Increment(ref _checkCalls) == 1;
|
||||
Enqueue(PacketBuilder.Build(RioCommand.CheckReply,
|
||||
new[] { (byte)RioStatusType.RestartCount, first ? (byte)3 : (byte)7 }));
|
||||
Enqueue(PacketBuilder.Build(RioCommand.CheckReply,
|
||||
new[] { (byte)RioStatusType.AbandonCount, (byte)0 }));
|
||||
Enqueue(PacketBuilder.Build(RioCommand.CheckReply,
|
||||
new[] { (byte)RioStatusType.FullBufferCount, first ? (byte)1 : (byte)2 }));
|
||||
}
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task ProduceAsync(CancellationToken ct)
|
||||
{
|
||||
bool pressSent = false, releaseSent = false;
|
||||
var analogPayload = new byte[10]; // all axes zero — valid sample
|
||||
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(50, ct).ConfigureAwait(false);
|
||||
double t = _clock.Elapsed.TotalSeconds;
|
||||
|
||||
bool inGap = t >= 4.0 && t < 6.7;
|
||||
if (!inGap)
|
||||
Enqueue(PacketBuilder.Build(RioCommand.AnalogReply, analogPayload));
|
||||
|
||||
if (!pressSent && t >= 6.5)
|
||||
{
|
||||
pressSent = true;
|
||||
Enqueue(PacketBuilder.Build(RioCommand.ButtonPressed, new byte[] { 0x05 }));
|
||||
}
|
||||
if (!releaseSent && t >= 7.0)
|
||||
{
|
||||
releaseSent = true;
|
||||
Enqueue(PacketBuilder.Build(RioCommand.ButtonReleased, new byte[] { 0x05 }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void Enqueue(byte[] packet)
|
||||
{
|
||||
_rx.Enqueue(packet);
|
||||
_rxReady.Release();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts.Cancel();
|
||||
_rxReady.Release(); // wake a pending read so the loop can wind down
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user