Both devices now serve \.\pipe\vrio and \.\pipe\vplasma for the whole app lifetime alongside the COM rows — the com0com-free path. Framing per the pinned contract (0x00 len data / 0x01 DTR+RTS lines, one lines frame on connect, unknown type = log + drop): PipeFraming/PipeFrameDecoder and VRioPipeService (TX paced at the wire rate, peer DTR edges feed HostHandshake) in VRio.Core, listener-only VPlasmaPipeService twin in VPlasma.Core. Busy pipe names retry, so vRIO's built-in glass and the standalone vPLASMA coexist. README documents the transport and the fork conf lines. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
328 lines
11 KiB
C#
328 lines
11 KiB
C#
using System.Diagnostics;
|
|
using System.IO.Pipes;
|
|
using VRio.Core.Device;
|
|
using VRio.Core.Protocol;
|
|
using Xunit;
|
|
|
|
namespace VRio.Core.Tests;
|
|
|
|
public class PipeFrameDecoderTests
|
|
{
|
|
[Fact]
|
|
public void Data_frame_delivers_payload()
|
|
{
|
|
var decoder = new PipeFrameDecoder();
|
|
byte[]? got = null;
|
|
decoder.Data += (buf, count) => got = buf.Take(count).ToArray();
|
|
|
|
Assert.True(decoder.Feed(new byte[] { 0x00, 0x03, 0xAA, 0xBB, 0xCC }, 5));
|
|
Assert.Equal(new byte[] { 0xAA, 0xBB, 0xCC }, got);
|
|
}
|
|
|
|
[Fact]
|
|
public void Frames_survive_any_split_across_reads()
|
|
{
|
|
var decoder = new PipeFrameDecoder();
|
|
var data = new List<byte>();
|
|
var lines = new List<byte>();
|
|
decoder.Data += (buf, count) => data.AddRange(buf.Take(count));
|
|
decoder.Lines += lines.Add;
|
|
|
|
// A lines frame, a 2-byte data frame, a 1-byte data frame — one byte at a time.
|
|
byte[] stream = { 0x01, 0x03, 0x00, 0x02, 0x81, 0x01, 0x00, 0x01, 0xFC };
|
|
foreach (byte b in stream)
|
|
Assert.True(decoder.Feed(new[] { b }, 1));
|
|
|
|
Assert.Equal(new byte[] { 0x03 }, lines);
|
|
Assert.Equal(new byte[] { 0x81, 0x01, 0xFC }, data);
|
|
}
|
|
|
|
[Fact]
|
|
public void Multiple_frames_in_one_read_all_deliver()
|
|
{
|
|
var decoder = new PipeFrameDecoder();
|
|
var data = new List<byte>();
|
|
var lines = new List<byte>();
|
|
decoder.Data += (buf, count) => data.AddRange(buf.Take(count));
|
|
decoder.Lines += lines.Add;
|
|
|
|
byte[] chunk = { 0x00, 0x01, 0x42, 0x01, 0x00, 0x00, 0x01, 0x43 };
|
|
Assert.True(decoder.Feed(chunk, chunk.Length));
|
|
|
|
Assert.Equal(new byte[] { 0x42, 0x43 }, data);
|
|
Assert.Equal(new byte[] { 0x00 }, lines);
|
|
}
|
|
|
|
[Fact]
|
|
public void Unknown_frame_type_poisons_the_decoder()
|
|
{
|
|
var decoder = new PipeFrameDecoder();
|
|
|
|
Assert.False(decoder.Feed(new byte[] { 0x7F }, 1));
|
|
Assert.Contains("0x7F", decoder.Violation);
|
|
// Poisoned: even a healthy frame is rejected until Reset.
|
|
Assert.False(decoder.Feed(new byte[] { 0x00, 0x01, 0x42 }, 3));
|
|
|
|
decoder.Reset();
|
|
Assert.Null(decoder.Violation);
|
|
Assert.True(decoder.Feed(new byte[] { 0x00, 0x01, 0x42 }, 3));
|
|
}
|
|
|
|
[Fact]
|
|
public void Zero_length_data_frame_is_a_violation()
|
|
{
|
|
var decoder = new PipeFrameDecoder();
|
|
Assert.False(decoder.Feed(new byte[] { 0x00, 0x00 }, 2));
|
|
Assert.Contains("zero-length", decoder.Violation);
|
|
}
|
|
}
|
|
|
|
public class VRioPipeServiceTests
|
|
{
|
|
/// <summary>
|
|
/// A contract-speaking client: connects to the service's pipe, deframes
|
|
/// everything it receives (timestamping data bytes for the pacing test),
|
|
/// and sends raw frame bytes.
|
|
/// </summary>
|
|
private sealed class TestClient : IDisposable
|
|
{
|
|
private readonly NamedPipeClientStream _pipe;
|
|
private readonly Thread _reader;
|
|
private readonly object _sync = new();
|
|
private readonly List<byte> _data = new();
|
|
private readonly List<long> _dataTicks = new();
|
|
private readonly List<byte> _lines = new();
|
|
private volatile bool _closed;
|
|
|
|
public TestClient(string pipeName)
|
|
{
|
|
_pipe = new NamedPipeClientStream(".", pipeName, PipeDirection.InOut,
|
|
PipeOptions.Asynchronous);
|
|
|
|
// The server's listener may still be spinning up; retry briefly.
|
|
var connectClock = Stopwatch.StartNew();
|
|
while (true)
|
|
{
|
|
try
|
|
{
|
|
_pipe.Connect(200);
|
|
break;
|
|
}
|
|
catch (Exception ex) when (ex is IOException or TimeoutException)
|
|
{
|
|
if (connectClock.ElapsedMilliseconds >= 3000)
|
|
throw;
|
|
Thread.Sleep(25);
|
|
}
|
|
}
|
|
|
|
var decoder = new PipeFrameDecoder();
|
|
decoder.Data += (buf, count) =>
|
|
{
|
|
long now = Stopwatch.GetTimestamp();
|
|
lock (_sync)
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
_data.Add(buf[i]);
|
|
_dataTicks.Add(now);
|
|
}
|
|
};
|
|
decoder.Lines += b => { lock (_sync) _lines.Add(b); };
|
|
|
|
_reader = new Thread(() =>
|
|
{
|
|
var buffer = new byte[512];
|
|
while (true)
|
|
{
|
|
int n;
|
|
try { n = _pipe.Read(buffer, 0, buffer.Length); }
|
|
catch (Exception ex) when (ex is IOException or ObjectDisposedException) { break; }
|
|
if (n == 0 || !decoder.Feed(buffer, n))
|
|
break;
|
|
}
|
|
_closed = true;
|
|
})
|
|
{ IsBackground = true };
|
|
_reader.Start();
|
|
}
|
|
|
|
public bool Closed => _closed;
|
|
public byte[] Data { get { lock (_sync) return _data.ToArray(); } }
|
|
public long[] DataTicks { get { lock (_sync) return _dataTicks.ToArray(); } }
|
|
public byte[] Lines { get { lock (_sync) return _lines.ToArray(); } }
|
|
|
|
public void SendRaw(params byte[] bytes)
|
|
{
|
|
_pipe.Write(bytes, 0, bytes.Length);
|
|
_pipe.Flush();
|
|
}
|
|
|
|
public void SendData(byte[] payload)
|
|
{
|
|
var frame = new byte[2 + payload.Length];
|
|
frame[0] = PipeFraming.DataType;
|
|
frame[1] = (byte)payload.Length;
|
|
payload.CopyTo(frame, 2);
|
|
SendRaw(frame);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_pipe.Dispose();
|
|
_reader.Join(1000);
|
|
}
|
|
}
|
|
|
|
private static string UniqueName() => $"vrio-test-{Guid.NewGuid():N}";
|
|
|
|
private static void WaitFor(Func<bool> condition, int timeoutMs = 2000)
|
|
{
|
|
var clock = Stopwatch.StartNew();
|
|
while (!condition())
|
|
{
|
|
Assert.True(clock.ElapsedMilliseconds < timeoutMs, "timed out waiting for a condition");
|
|
Thread.Sleep(10);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Client_gets_board_present_lines_on_connect()
|
|
{
|
|
string name = UniqueName();
|
|
var device = new VRioDevice();
|
|
using var service = new VRioPipeService(device, name);
|
|
service.Start();
|
|
|
|
using var client = new TestClient(name);
|
|
WaitFor(() => client.Lines.Length > 0);
|
|
|
|
Assert.Equal(PipeFraming.LinesPresent, client.Lines[0]); // DTR+RTS: board present
|
|
}
|
|
|
|
[Fact]
|
|
public void Peer_dtr_edges_surface_as_host_handshake()
|
|
{
|
|
string name = UniqueName();
|
|
var device = new VRioDevice();
|
|
using var service = new VRioPipeService(device, name);
|
|
var edges = new List<bool>();
|
|
service.HostHandshake += high => { lock (edges) edges.Add(high); };
|
|
service.Start();
|
|
|
|
using var client = new TestClient(name);
|
|
client.SendRaw(PipeFraming.LinesType, PipeFraming.LineDtr); // DTR up
|
|
client.SendRaw(PipeFraming.LinesType, 0x00); // DTR down (the reset pulse)
|
|
|
|
WaitFor(() => { lock (edges) return edges.Count == 2; });
|
|
lock (edges)
|
|
Assert.Equal(new[] { true, false }, edges);
|
|
}
|
|
|
|
[Fact]
|
|
public void Version_request_round_trips_ack_then_reply()
|
|
{
|
|
string name = UniqueName();
|
|
var device = new VRioDevice();
|
|
using var service = new VRioPipeService(device, name);
|
|
service.Start();
|
|
|
|
using var client = new TestClient(name);
|
|
WaitFor(() => client.Lines.Length > 0); // fully connected
|
|
client.SendData(PacketBuilder.Build(RioCommand.VersionRequest));
|
|
|
|
byte[] expected = new[] { (byte)RioControl.Ack }
|
|
.Concat(PacketBuilder.VersionReply(4, 2)).ToArray();
|
|
WaitFor(() => client.Data.Length >= expected.Length);
|
|
|
|
Assert.Equal(expected, client.Data);
|
|
}
|
|
|
|
[Fact]
|
|
public void Reply_bytes_are_paced_at_the_wire_rate()
|
|
{
|
|
string name = UniqueName();
|
|
var device = new VRioDevice();
|
|
using var service = new VRioPipeService(device, name);
|
|
service.Start();
|
|
|
|
using var client = new TestClient(name);
|
|
WaitFor(() => client.Lines.Length > 0);
|
|
client.SendData(PacketBuilder.Build(RioCommand.VersionRequest));
|
|
|
|
// ACK + 4-byte VersionReply = 5 bytes = 4 inter-byte gaps ≈ 4.2 ms at
|
|
// 9600 baud. An unpaced writer would land them in well under 1 ms.
|
|
WaitFor(() => client.Data.Length >= 5);
|
|
long[] ticks = client.DataTicks;
|
|
double spanMs = (ticks[4] - ticks[0]) * 1000.0 / Stopwatch.Frequency;
|
|
|
|
Assert.True(spanMs >= 2.5, $"5 reply bytes arrived in {spanMs:F2} ms — pipe TX is not paced");
|
|
}
|
|
|
|
[Fact]
|
|
public void Unknown_frame_type_drops_the_client_and_server_recovers()
|
|
{
|
|
string name = UniqueName();
|
|
var device = new VRioDevice();
|
|
using var service = new VRioPipeService(device, name);
|
|
service.Start();
|
|
|
|
using (var bad = new TestClient(name))
|
|
{
|
|
WaitFor(() => bad.Lines.Length > 0);
|
|
bad.SendRaw(0x7F); // not a frame type
|
|
WaitFor(() => bad.Closed); // contract: log + drop, no resync
|
|
}
|
|
|
|
// The listener must come back for the next client.
|
|
using var good = new TestClient(name);
|
|
WaitFor(() => good.Lines.Length > 0);
|
|
Assert.Equal(PipeFraming.LinesPresent, good.Lines[0]);
|
|
}
|
|
|
|
[Fact]
|
|
public void Client_disconnect_drops_dtr_low()
|
|
{
|
|
string name = UniqueName();
|
|
var device = new VRioDevice();
|
|
using var service = new VRioPipeService(device, name);
|
|
var edges = new List<bool>();
|
|
service.HostHandshake += high => { lock (edges) edges.Add(high); };
|
|
service.Start();
|
|
|
|
using (var client = new TestClient(name))
|
|
{
|
|
client.SendRaw(PipeFraming.LinesType, PipeFraming.LineDtr);
|
|
WaitFor(() => { lock (edges) return edges.Count == 1; });
|
|
} // client leaves with DTR still high
|
|
|
|
WaitFor(() => { lock (edges) return edges.Count == 2; });
|
|
lock (edges)
|
|
Assert.Equal(new[] { true, false }, edges);
|
|
}
|
|
|
|
[Fact]
|
|
public void Data_split_mid_frame_still_reaches_the_device()
|
|
{
|
|
string name = UniqueName();
|
|
var device = new VRioDevice();
|
|
using var service = new VRioPipeService(device, name);
|
|
var lamps = new List<(int Address, byte State)>();
|
|
device.LampChanged += (address, state) => { lock (lamps) lamps.Add((address, state)); };
|
|
service.Start();
|
|
|
|
using var client = new TestClient(name);
|
|
WaitFor(() => client.Lines.Length > 0);
|
|
|
|
// LampRequest 0x00 ← 0x01, framed, then torn in half mid-payload.
|
|
byte[] packet = PacketBuilder.Build(RioCommand.LampRequest, new byte[] { 0x00, 0x01 });
|
|
var frame = new byte[] { PipeFraming.DataType, (byte)packet.Length }.Concat(packet).ToArray();
|
|
client.SendRaw(frame.Take(3).ToArray());
|
|
Thread.Sleep(20);
|
|
client.SendRaw(frame.Skip(3).ToArray());
|
|
|
|
WaitFor(() => { lock (lamps) return lamps.Count == 1; });
|
|
lock (lamps)
|
|
Assert.Equal((0x00, (byte)0x01), lamps[0]);
|
|
}
|
|
}
|