diff --git a/README.md b/README.md
index 87bdf2e..49bb238 100644
--- a/README.md
+++ b/README.md
@@ -52,3 +52,16 @@ auto-switch), and the HID report packer that matches the driver's wire format.
Remaining work is **on-cabinet** (real RIO serial/axis/plasma/auto-switch
verification) plus packaging (Phase 6) and the profile editor + overlay
generator (Phase 7). See [`docs/PLAN.md`](docs/PLAN.md) for the full roadmap.
+
+## Testing without hardware: vRIO over a named pipe
+
+The [vRIO](https://gitea.mysticmachines.com/VWE/VRIO) device emulator can stand
+in for the real board with no com0com pair: anywhere a COM port name is
+configured — a profile's `RioComPort`, the app-wide `DefaultRioComPort`, or the
+`RioSerialMonitor` `[port]` argument — the endpoint `pipe:vrio` connects to
+vRIO's `\\.\pipe\vrio` instead (vRIO must have its pipe endpoint open). Serial
+bytes and modem lines (including the DTR reset pulse on open) travel as typed
+frames over the pipe; the contract lives in
+[`src/RioJoy.Core/Serial/PipeFraming.cs`](src/RioJoy.Core/Serial/PipeFraming.cs)
+on this side and vRIO's `PipeFraming.cs` / the DOSBox-X fork's
+`serialnamedpipe.h` on the others.
diff --git a/src/RioJoy.Core/Profiles/AppConfig.cs b/src/RioJoy.Core/Profiles/AppConfig.cs
index 9d511ef..6b71e2a 100644
--- a/src/RioJoy.Core/Profiles/AppConfig.cs
+++ b/src/RioJoy.Core/Profiles/AppConfig.cs
@@ -7,7 +7,11 @@ namespace RioJoy.Core.Profiles;
///
public sealed class AppConfig
{
- /// Default RIO COM port when a profile doesn't specify one.
+ ///
+ /// Default RIO endpoint when a profile doesn't specify one: a COM port
+ /// name ("COM1"), or "pipe:vrio" to reach the vRIO emulator over its
+ /// named pipe (no com0com pair; see RioTransportFactory).
+ ///
public string DefaultRioComPort { get; set; } = "COM1";
///
diff --git a/src/RioJoy.Core/Profiles/RioProfile.cs b/src/RioJoy.Core/Profiles/RioProfile.cs
index 7fc6b6a..5304a41 100644
--- a/src/RioJoy.Core/Profiles/RioProfile.cs
+++ b/src/RioJoy.Core/Profiles/RioProfile.cs
@@ -15,7 +15,7 @@ public sealed class RioProfile
/// Display name (unique within a library).
public string Name { get; set; } = "Unnamed";
- /// RIO serial port (e.g. "COM3"); null = use the app default.
+ /// RIO endpoint ("COM3", or "pipe:vrio" for the emulator); null = use the app default.
public string? RioComPort { get; set; }
/// Plasma/VFD serial port; null = use the app default or none.
diff --git a/src/RioJoy.Core/Serial/NamedPipeTransport.cs b/src/RioJoy.Core/Serial/NamedPipeTransport.cs
new file mode 100644
index 0000000..8351231
--- /dev/null
+++ b/src/RioJoy.Core/Serial/NamedPipeTransport.cs
@@ -0,0 +1,174 @@
+using System.IO.Pipes;
+
+namespace RioJoy.Core.Serial;
+
+///
+/// over a local named pipe, for driving the vRIO
+/// device emulator without com0com. vRIO serves \\.\pipe\vrio for the
+/// whole app lifetime (the device is always present); we connect as the
+/// client — the same role the DOSBox-X fork's namedpipe serial backend
+/// plays. Framing per : writes wrap in data frames,
+/// reads unwrap them, and modem lines travel in-band as lines frames.
+///
+/// On connect this replays 's board
+/// reset over the pipe: a lines frame asserting DTR, the
+/// hold, then a lines frame
+/// releasing it (RTS stays low throughout, matching the COM path's
+/// RtsEnable default). The in-band frames keep the pulse's exact position
+/// in the byte stream, which is why the contract multiplexes control onto
+/// the data pipe instead of using a second one.
+///
+/// The peer's lines frames (vRIO asserts DTR+RTS on connect — "board
+/// present") are decoded and tracked but nothing consumes them: the COM path
+/// runs Handshake.None and never reads DSR/CTS either. A peer disconnect or
+/// framing violation surfaces as a 0-byte read — the transport-closed signal
+/// already understands, same as a yanked
+/// adapter.
+///
+/// Writes are not paced: host→RIO traffic is tiny stop-and-wait
+/// commands, and the com0com path never emulated baud timing on this
+/// direction either. (vRIO paces its own RIO→host TX, where the analog
+/// stream would otherwise burst.)
+///
+public sealed class NamedPipeTransport : IRioTransport
+{
+ /// How long to wait for the pipe server before giving up.
+ public static readonly TimeSpan DefaultConnectTimeout = TimeSpan.FromSeconds(2);
+
+ private readonly NamedPipeClientStream _pipe;
+ private readonly string _pipeName;
+ private readonly PipeFrameDecoder _decoder = new();
+
+ // Decoded data bytes not yet handed to a reader (a pipe read may carry
+ // more payload than the caller's buffer holds). Only the receive loop
+ // touches these — IRioTransport has a single reader by contract.
+ private readonly Queue _decoded = new();
+ private readonly byte[] _raw = new byte[512];
+ private volatile bool _closed;
+ private byte _peerLines;
+
+ /// Pipe name without the \\.\pipe\ prefix, e.g. "vrio".
+ /// Server wait; default .
+ public NamedPipeTransport(string pipeName, TimeSpan? connectTimeout = null)
+ {
+ if (string.IsNullOrWhiteSpace(pipeName)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(pipeName));
+ _pipeName = pipeName;
+
+ _decoder.Data += (buffer, count) =>
+ {
+ for (int i = 0; i < count; i++)
+ _decoded.Enqueue(buffer[i]);
+ };
+ _decoder.Lines += lines => _peerLines = lines;
+
+ _pipe = new NamedPipeClientStream(".", pipeName, PipeDirection.InOut, PipeOptions.Asynchronous);
+ try
+ {
+ _pipe.Connect((int)(connectTimeout ?? DefaultConnectTimeout).TotalMilliseconds);
+ }
+ catch (TimeoutException)
+ {
+ _pipe.Dispose();
+ throw new TimeoutException($@"No pipe server at \\.\pipe\{pipeName} — is vRIO running?");
+ }
+ catch
+ {
+ _pipe.Dispose();
+ throw;
+ }
+
+ // DTR reset pulse, in-band: assert, hold, release (port of the COM
+ // path's SETDTR/CLRDTR — see SerialPortTransport and PROTOCOL.md §1).
+ //
+ // Queued as overlapped writes, never awaited here: the pipe's buffers
+ // default to 0 bytes, so a synchronous write blocks until the peer
+ // reads it — and vRIO's own on-connect lines frame is written before
+ // it starts reading, a write-first/write-first deadlock if we blocked
+ // too. Pipe writes complete in issue order, so the assert→release
+ // edge keeps its exact position in the byte stream; both frames
+ // drain once the two readers are up. (When vRIO is already reading —
+ // the steady case — the 50 ms hold arrives in real time too; during
+ // the startup rendezvous the device may see a shorter pulse, which
+ // still carries both edges.)
+ try
+ {
+ byte[] assert = PipeFraming.EncodeLines(PipeFraming.LineDtr);
+ Observe(_pipe.WriteAsync(assert, 0, assert.Length, CancellationToken.None));
+ Thread.Sleep(SerialPortTransport.DtrPulse);
+ byte[] release = PipeFraming.EncodeLines(0);
+ Observe(_pipe.WriteAsync(release, 0, release.Length, CancellationToken.None));
+ }
+ catch
+ {
+ _pipe.Dispose();
+ throw;
+ }
+ }
+
+ // Swallow a queued write's eventual fault (e.g. the peer vanished before
+ // draining it) — on net40 an unobserved task exception kills the process.
+ private static void Observe(Task task) =>
+ task.ContinueWith(t => { _ = t.Exception; }, TaskContinuationOptions.ExecuteSynchronously);
+
+ public string Description => $@"\\.\pipe\{_pipeName}";
+
+ /// The peer's last lines frame (bit0 its DTR → our DSR, bit1 its RTS → our CTS).
+ public byte PeerLines => _peerLines;
+
+ /// Why the peer's stream was rejected, or null (diagnostic; see ).
+ public string? Violation => _decoder.Violation;
+
+ public async Task ReadAsync(byte[] buffer, CancellationToken cancellationToken)
+ {
+ while (_decoded.Count == 0)
+ {
+ if (_closed)
+ return 0;
+
+ int n;
+ try
+ {
+ n = await _pipe.ReadAsync(_raw, 0, _raw.Length, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (_closed && ex is IOException or ObjectDisposedException)
+ {
+ return 0; // Dispose broke the pending read — normal teardown
+ }
+
+ if (n == 0)
+ {
+ _closed = true; // server went away — unplugged cable
+ return 0;
+ }
+
+ if (!_decoder.Feed(_raw, n))
+ {
+ _closed = true; // framing violation: not line noise on a pipe, drop the link
+ return 0;
+ }
+ }
+
+ int count = Math.Min(buffer.Length, _decoded.Count);
+ for (int i = 0; i < count; i++)
+ buffer[i] = _decoded.Dequeue();
+ return count;
+ }
+
+ public Task WriteAsync(byte[] data, CancellationToken cancellationToken)
+ {
+ // Empty input frames to an empty array; the 0-byte write is a no-op.
+ byte[] framed = PipeFraming.EncodeData(data);
+ return _pipe.WriteAsync(framed, 0, framed.Length, cancellationToken);
+ }
+
+ public void Dispose()
+ {
+ // Closing the handle aborts a pending overlapped read (the receive
+ // loop's read ignores cancellation, same as the COM path — teardown
+ // relies on this). Pipes have no FlushFileBuffers hang to guard
+ // against, so no grace-period dance like SerialPortTransport's.
+ _closed = true;
+ try { _pipe.Dispose(); }
+ catch (IOException) { }
+ }
+}
diff --git a/src/RioJoy.Core/Serial/PipeFraming.cs b/src/RioJoy.Core/Serial/PipeFraming.cs
new file mode 100644
index 0000000..d166cf9
--- /dev/null
+++ b/src/RioJoy.Core/Serial/PipeFraming.cs
@@ -0,0 +1,160 @@
+namespace RioJoy.Core.Serial;
+
+///
+/// The typed-frame contract for serial-over-named-pipe, shared with vRIO
+/// (VRio.Core/Device/PipeFraming.cs) and the DOSBox-X fork's
+/// namedpipe serial backend (serialnamedpipe.h is the contract's
+/// source of truth on that side). A pipe is a plain byte stream, so serial
+/// data and modem control lines are multiplexed as typed frames:
+///
+///
+/// 0x00 <len:u8> <len bytes> serial data, len ≥ 1 (batching allowed)
+/// 0x01 <lines:u8> the sender's OWN output lines (bit0 DTR,
+/// bit1 RTS); the receiver applies the
+/// null-modem cross: peer DTR → local DSR,
+/// peer RTS → local CTS
+///
+///
+/// Each side sends one lines frame immediately on connect; until it arrives
+/// the peer's lines are assumed low, and a disconnect drops them low again.
+/// Any other frame type is a protocol bug, not line noise — pipes don't drop
+/// bytes — so the receiver drops the connection instead of trying to resync.
+///
+public static class PipeFraming
+{
+ public const byte DataType = 0x00;
+ public const byte LinesType = 0x01;
+
+ /// Lines-frame bit: the sender's DTR output.
+ public const byte LineDtr = 0x01;
+
+ /// Lines-frame bit: the sender's RTS output.
+ public const byte LineRts = 0x02;
+
+ /// Largest payload one data frame can carry (u8 length).
+ public const int MaxDataPayload = byte.MaxValue;
+
+ /// Build a lines frame carrying .
+ public static byte[] EncodeLines(byte lines) => new[] { LinesType, lines };
+
+ ///
+ /// Wrap in data frames, chunking payloads longer
+ /// than . Empty input yields an empty array
+ /// (the contract forbids zero-length data frames).
+ ///
+ public static byte[] EncodeData(byte[] data)
+ {
+ if (data is null) throw new ArgumentNullException(nameof(data));
+ if (data.Length == 0)
+ return new byte[0]; // net40 has no Array.Empty
+
+ int chunks = (data.Length + MaxDataPayload - 1) / MaxDataPayload;
+ var framed = new byte[data.Length + chunks * 2];
+ int src = 0, dst = 0;
+ while (src < data.Length)
+ {
+ int len = Math.Min(MaxDataPayload, data.Length - src);
+ framed[dst++] = DataType;
+ framed[dst++] = (byte)len;
+ Array.Copy(data, src, framed, dst, len);
+ src += len;
+ dst += len;
+ }
+ return framed;
+ }
+}
+
+///
+/// Incremental decoder for : feed it raw pipe reads,
+/// get one event per complete data frame and one
+/// event per lines frame. Frames may split across reads
+/// at any byte boundary. A malformed stream (unknown type, zero-length data
+/// frame) poisons the decoder: returns false and
+/// says why — drop the connection and
+/// before the next one.
+///
+public sealed class PipeFrameDecoder
+{
+ private enum State { Type, Length, Payload, Lines }
+
+ private readonly byte[] _payload = new byte[byte.MaxValue];
+ private State _state;
+ private int _fill, _length;
+
+ ///
+ /// A complete data frame's payload as (buffer, count). The buffer is
+ /// reused across frames — consume it synchronously.
+ ///
+ public event Action? Data;
+
+ /// A lines frame's bits (see ).
+ public event Action? Lines;
+
+ /// Why the stream was rejected, or null while it is healthy.
+ public string? Violation { get; private set; }
+
+ /// Forget any partial frame and clear a violation (new connection).
+ public void Reset()
+ {
+ _state = State.Type;
+ _fill = _length = 0;
+ Violation = null;
+ }
+
+ ///
+ /// Consume received bytes from
+ /// . Returns false when the stream violates the
+ /// framing contract (see ); a poisoned decoder
+ /// keeps returning false until .
+ ///
+ public bool Feed(byte[] buffer, int count)
+ {
+ if (Violation is not null)
+ return false;
+
+ for (int i = 0; i < count; i++)
+ {
+ byte b = buffer[i];
+ switch (_state)
+ {
+ case State.Type when b == PipeFraming.DataType:
+ _state = State.Length;
+ break;
+
+ case State.Type when b == PipeFraming.LinesType:
+ _state = State.Lines;
+ break;
+
+ case State.Type:
+ Violation = $"unknown frame type 0x{b:X2}";
+ return false;
+
+ case State.Length when b == 0:
+ Violation = "zero-length data frame";
+ return false;
+
+ case State.Length:
+ _length = b;
+ _fill = 0;
+ _state = State.Payload;
+ break;
+
+ case State.Payload:
+ _payload[_fill++] = b;
+ if (_fill == _length)
+ {
+ _state = State.Type;
+ Data?.Invoke(_payload, _length);
+ }
+ break;
+
+ case State.Lines:
+ _state = State.Type;
+ Lines?.Invoke(b);
+ break;
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/src/RioJoy.Core/Serial/RioTransportFactory.cs b/src/RioJoy.Core/Serial/RioTransportFactory.cs
new file mode 100644
index 0000000..5cf15e4
--- /dev/null
+++ b/src/RioJoy.Core/Serial/RioTransportFactory.cs
@@ -0,0 +1,33 @@
+namespace RioJoy.Core.Serial;
+
+///
+/// Opens the right for an endpoint string. A COM
+/// port name ("COM3") opens a ; a
+/// pipe:name endpoint ("pipe:vrio") opens a
+/// to \\.\pipe\name — the same
+/// endpoint syntax vRIO's own connection picker uses, so a profile can point
+/// at the emulator with RioComPort = "pipe:vrio" and no com0com pair.
+///
+public static class RioTransportFactory
+{
+ /// Endpoint prefix selecting the named-pipe transport.
+ public const string PipeScheme = "pipe:";
+
+ /// True when names a pipe rather than a COM port.
+ public static bool IsPipe(string endpoint) =>
+ endpoint is not null
+ && endpoint.StartsWith(PipeScheme, StringComparison.OrdinalIgnoreCase);
+
+ ///
+ /// Open . applies
+ /// to COM ports only — a pipe has no wire rate (the peer paces itself).
+ ///
+ public static IRioTransport Open(string endpoint, int baudRate = SerialPortTransport.BaudRate)
+ {
+ if (string.IsNullOrWhiteSpace(endpoint)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(endpoint));
+
+ return IsPipe(endpoint)
+ ? new NamedPipeTransport(endpoint.Substring(PipeScheme.Length))
+ : (IRioTransport)new SerialPortTransport(endpoint, baudRate);
+ }
+}
diff --git a/src/RioJoy.Tray/RioCoordinator.cs b/src/RioJoy.Tray/RioCoordinator.cs
index 0468253..c17cc39 100644
--- a/src/RioJoy.Tray/RioCoordinator.cs
+++ b/src/RioJoy.Tray/RioCoordinator.cs
@@ -53,7 +53,7 @@ public sealed class RioCoordinator : IDisposable
{
_config = config ?? throw new ArgumentNullException(nameof(config));
_transportFactory = transportFactory
- ?? (port => new SerialPortTransport(port, _config().RioBaudRate));
+ ?? (port => RioTransportFactory.Open(port, _config().RioBaudRate));
}
/// Raised (with a short status string) whenever the active state changes.
diff --git a/tests/RioJoy.Core.Tests/Serial/NamedPipeTransportTests.cs b/tests/RioJoy.Core.Tests/Serial/NamedPipeTransportTests.cs
new file mode 100644
index 0000000..25e5aa6
--- /dev/null
+++ b/tests/RioJoy.Core.Tests/Serial/NamedPipeTransportTests.cs
@@ -0,0 +1,275 @@
+using System.IO.Pipes;
+using RioJoy.Core.Serial;
+using Xunit;
+
+namespace RioJoy.Core.Tests.Serial;
+
+public class PipeFramingTests
+{
+ [Fact]
+ public void EncodeData_WrapsInOneFrame()
+ {
+ byte[] framed = PipeFraming.EncodeData(new byte[] { 0x81, 0x01, 0xFC });
+ Assert.Equal(new byte[] { 0x00, 0x03, 0x81, 0x01, 0xFC }, framed);
+ }
+
+ [Fact]
+ public void EncodeData_ChunksPayloadsOver255()
+ {
+ var data = new byte[300];
+ for (int i = 0; i < data.Length; i++) data[i] = (byte)i;
+
+ byte[] framed = PipeFraming.EncodeData(data);
+
+ // 255-byte frame + 45-byte frame, payloads contiguous.
+ Assert.Equal(300 + 4, framed.Length);
+ Assert.Equal(PipeFraming.DataType, framed[0]);
+ Assert.Equal(255, framed[1]);
+ Assert.Equal(PipeFraming.DataType, framed[2 + 255]);
+ Assert.Equal(45, framed[2 + 255 + 1]);
+ Assert.Equal(data.Take(255), framed.Skip(2).Take(255));
+ Assert.Equal(data.Skip(255), framed.Skip(2 + 255 + 2));
+ }
+
+ [Fact]
+ public void EncodeData_EmptyYieldsNoFrames()
+ {
+ Assert.Empty(PipeFraming.EncodeData(new byte[0]));
+ }
+
+ [Fact]
+ public void Decoder_SurvivesAnySplitAcrossReads()
+ {
+ var decoder = new PipeFrameDecoder();
+ var data = new List();
+ var lines = new List();
+ 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 — fed 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 Decoder_UnknownFrameType_Poisons()
+ {
+ var decoder = new PipeFrameDecoder();
+
+ Assert.False(decoder.Feed(new byte[] { 0xFF }, 1));
+ Assert.Contains("0xFF", decoder.Violation);
+ Assert.False(decoder.Feed(new byte[] { 0x00, 0x01, 0x42 }, 3)); // stays poisoned
+
+ decoder.Reset();
+ Assert.True(decoder.Feed(new byte[] { 0x00, 0x01, 0x42 }, 3));
+ Assert.Null(decoder.Violation);
+ }
+
+ [Fact]
+ public void Decoder_ZeroLengthDataFrame_Poisons()
+ {
+ var decoder = new PipeFrameDecoder();
+ Assert.False(decoder.Feed(new byte[] { 0x00, 0x00 }, 2));
+ Assert.Contains("zero-length", decoder.Violation);
+ }
+}
+
+public class RioTransportFactoryTests
+{
+ [Theory]
+ [InlineData("pipe:vrio", true)]
+ [InlineData("PIPE:vrio", true)]
+ [InlineData("COM3", false)]
+ [InlineData("com1", false)]
+ public void IsPipe_RecognizesTheScheme(string endpoint, bool expected)
+ {
+ Assert.Equal(expected, RioTransportFactory.IsPipe(endpoint));
+ }
+}
+
+///
+/// Integration tests against a real in-process
+/// standing in for vRIO (which serves \\.\pipe\vrio the same way).
+///
+public class NamedPipeTransportTests : IDisposable
+{
+ private readonly string _pipeName = $"riojoy-test-{Guid.NewGuid():N}";
+ private readonly NamedPipeServerStream _server;
+
+ public NamedPipeTransportTests()
+ {
+ _server = new NamedPipeServerStream(_pipeName, PipeDirection.InOut, 1,
+ PipeTransmissionMode.Byte, PipeOptions.Asynchronous);
+ }
+
+ public void Dispose()
+ {
+ try { _server.Dispose(); }
+ catch (IOException) { }
+ }
+
+ /// Accept the client and construct the transport concurrently (the ctor blocks through the DTR pulse).
+ private async Task ConnectAsync()
+ {
+ Task accept = _server.WaitForConnectionAsync();
+ Task client = Task.Run(() => new NamedPipeTransport(_pipeName));
+ await accept.WithTimeout();
+ return await client.WithTimeout();
+ }
+
+ private async Task ServerReadAsync(int count)
+ {
+ var buffer = new byte[count];
+ int fill = 0;
+ while (fill < count)
+ {
+ int n = await _server.ReadAsync(buffer, fill, count - fill).WithTimeout();
+ Assert.True(n > 0, "server: pipe closed before the expected bytes arrived");
+ fill += n;
+ }
+ return buffer;
+ }
+
+ [Fact]
+ public async Task Connect_SendsTheDtrResetPulse()
+ {
+ using NamedPipeTransport transport = await ConnectAsync();
+
+ // Assert (DTR high), hold, release — the in-band SETDTR/CLRDTR port.
+ Assert.Equal(new byte[] { 0x01, PipeFraming.LineDtr }, await ServerReadAsync(2));
+ Assert.Equal(new byte[] { 0x01, 0x00 }, await ServerReadAsync(2));
+ }
+
+ [Fact]
+ public async Task ReadAsync_UnwrapsDataFrames_AndSwallowsLinesFrames()
+ {
+ using NamedPipeTransport transport = await ConnectAsync();
+
+ // vRIO's on-connect lines frame (board present), then a data frame —
+ // split at an awkward boundary to exercise the incremental decoder.
+ // Issued unawaited: the pipe's 0-byte buffers make a write complete
+ // only when the peer reads it (see the transport's ctor comment).
+ Task w1 = _server.WriteAsync(new byte[] { 0x01, 0x03, 0x00, 0x03, 0x81 }, 0, 5);
+ Task w2 = _server.WriteAsync(new byte[] { 0x01, 0xFC }, 0, 2);
+
+ var buffer = new byte[16];
+ var got = new List();
+ while (got.Count < 3)
+ {
+ int n = await transport.ReadAsync(buffer, CancellationToken.None).WithTimeout();
+ Assert.True(n > 0);
+ got.AddRange(buffer.Take(n));
+ }
+
+ Assert.Equal(new byte[] { 0x81, 0x01, 0xFC }, got);
+ Assert.Equal((byte)(PipeFraming.LineDtr | PipeFraming.LineRts), transport.PeerLines);
+ await w1.WithTimeout();
+ await w2.WithTimeout();
+ }
+
+ [Fact]
+ public async Task ReadAsync_SmallBuffer_DrainsAcrossCalls()
+ {
+ using NamedPipeTransport transport = await ConnectAsync();
+
+ Task write = _server.WriteAsync(new byte[] { 0x00, 0x04, 0x10, 0x20, 0x30, 0x40 }, 0, 6);
+
+ var buffer = new byte[3];
+ Assert.Equal(3, await transport.ReadAsync(buffer, CancellationToken.None).WithTimeout());
+ Assert.Equal(new byte[] { 0x10, 0x20, 0x30 }, buffer);
+ Assert.Equal(1, await transport.ReadAsync(buffer, CancellationToken.None).WithTimeout());
+ Assert.Equal(0x40, buffer[0]);
+ await write.WithTimeout();
+ }
+
+ [Fact]
+ public async Task WriteAsync_WrapsInADataFrame()
+ {
+ using NamedPipeTransport transport = await ConnectAsync();
+ await ServerReadAsync(4); // discard the DTR pulse frames
+
+ // Unawaited until the server drains it (0-byte pipe buffers).
+ Task write = transport.WriteAsync(new byte[] { 0x81, 0x01 }, CancellationToken.None);
+
+ Assert.Equal(new byte[] { 0x00, 0x02, 0x81, 0x01 }, await ServerReadAsync(4));
+ await write.WithTimeout();
+ }
+
+ [Fact]
+ public async Task ServerGone_ReadReturnsZero()
+ {
+ using NamedPipeTransport transport = await ConnectAsync();
+
+ _server.Dispose();
+
+ var buffer = new byte[16];
+ Assert.Equal(0, await transport.ReadAsync(buffer, CancellationToken.None).WithTimeout());
+ // And it keeps saying closed rather than reading a dead pipe.
+ Assert.Equal(0, await transport.ReadAsync(buffer, CancellationToken.None).WithTimeout());
+ }
+
+ [Fact]
+ public async Task ProtocolViolation_ReadReturnsZero()
+ {
+ using NamedPipeTransport transport = await ConnectAsync();
+
+ Task write = _server.WriteAsync(new byte[] { 0xFF }, 0, 1);
+
+ var buffer = new byte[16];
+ Assert.Equal(0, await transport.ReadAsync(buffer, CancellationToken.None).WithTimeout());
+ Assert.Contains("0xFF", transport.Violation);
+ await write.WithTimeout();
+ }
+
+ [Fact]
+ public async Task Dispose_UnblocksAPendingRead()
+ {
+ NamedPipeTransport transport = await ConnectAsync();
+
+ Task pending = transport.ReadAsync(new byte[16], CancellationToken.None);
+ Assert.False(pending.IsCompleted);
+
+ transport.Dispose();
+
+ Assert.Equal(0, await pending.WithTimeout());
+ }
+
+ [Fact]
+ public void NoServer_ConstructorTimesOutWithAClearMessage()
+ {
+ var ex = Assert.Throws(() =>
+ new NamedPipeTransport($"riojoy-nobody-{Guid.NewGuid():N}", TimeSpan.FromMilliseconds(200)));
+ Assert.Contains("vRIO", ex.Message);
+ }
+
+ [Fact]
+ public async Task Factory_OpensPipeEndpoints()
+ {
+ Task accept = _server.WaitForConnectionAsync();
+ Task client = Task.Run(() => RioTransportFactory.Open($"pipe:{_pipeName}"));
+ await accept.WithTimeout();
+
+ using IRioTransport transport = await client.WithTimeout();
+ Assert.Equal($@"\\.\pipe\{_pipeName}", transport.Description);
+ }
+}
+
+internal static class TaskTimeoutExtensions
+{
+ /// Await with a test-failure deadline, so a hung pipe fails fast instead of stalling the run.
+ public static async Task WithTimeout(this Task task, int seconds = 5)
+ {
+ Assert.Same(task, await Task.WhenAny(task, Task.Delay(TimeSpan.FromSeconds(seconds))));
+ return await task;
+ }
+
+ public static async Task WithTimeout(this Task task, int seconds = 5)
+ {
+ Assert.Same(task, await Task.WhenAny(task, Task.Delay(TimeSpan.FromSeconds(seconds))));
+ await task;
+ }
+}
diff --git a/tools/RioSerialMonitor/E0Test.cs b/tools/RioSerialMonitor/E0Test.cs
index 3f61e2a..c8f395b 100644
--- a/tools/RioSerialMonitor/E0Test.cs
+++ b/tools/RioSerialMonitor/E0Test.cs
@@ -70,7 +70,7 @@ internal static class E0Test
int frameCountdown = 0; // >0: inside a reply frame, bytes remaining
bool ackSent = false;
- SerialPortTransport? transport = null;
+ IRioTransport? transport = null;
Task? reader = null;
void PhaseReset(AckMode mode, int expectReply, int expectLen)
@@ -93,7 +93,7 @@ internal static class E0Test
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); }
+ try { transport = RioTransportFactory.Open(port, tryBaud); }
catch (Exception ex)
{
Console.WriteLine($" FAILED to open {port}: {ex.GetType().Name}: {ex.Message}");
@@ -101,7 +101,7 @@ internal static class E0Test
}
lock (gate) { buffer.Clear(); cursor = 0; }
- SerialPortTransport t = transport;
+ IRioTransport t = transport;
reader = Task.Run(async () =>
{
var tmp = new byte[256];
diff --git a/tools/RioSerialMonitor/MashTest.cs b/tools/RioSerialMonitor/MashTest.cs
index 7859730..afc307f 100644
--- a/tools/RioSerialMonitor/MashTest.cs
+++ b/tools/RioSerialMonitor/MashTest.cs
@@ -110,7 +110,7 @@ internal static class MashTest
IRioTransport transport;
try
{
- transport = selftest ? new SelftestTransport() : new SerialPortTransport(port, baud);
+ transport = selftest ? new SelftestTransport() : RioTransportFactory.Open(port, baud);
}
catch (Exception ex)
{
diff --git a/tools/RioSerialMonitor/Program.cs b/tools/RioSerialMonitor/Program.cs
index 294a8b9..a69562a 100644
--- a/tools/RioSerialMonitor/Program.cs
+++ b/tools/RioSerialMonitor/Program.cs
@@ -9,6 +9,8 @@ using RioJoy.Core.Serial;
// It flashes all lamps once to prove the PC -> RIO output path, then echoes a lamp
// on each button press so a physical press lights up.
// dotnet run --project tools/RioSerialMonitor -- [port] [seconds] [--baud rate]
+// [port] is a COM name or a pipe endpoint: pipe:vrio connects to the vRIO
+// emulator's \\.\pipe\vrio (no com0com pair; vRIO must have its pipe open).
// Firmware wedge-patch validation (RIO_TAP mash test, see MashTest.cs):
// dotnet run --project tools/RioSerialMonitor -- --mash [port] [seconds]
// [--label baseline|patched] [--no-lamps] [--wedge seconds] [--baud rate]
@@ -45,10 +47,10 @@ void Log(string msg)
Console.WriteLine($"== RIO serial monitor :: {port} @ {baud} 8N1 for {seconds}s ==");
-SerialPortTransport transport;
+IRioTransport transport;
try
{
- transport = new SerialPortTransport(port, baud);
+ transport = RioTransportFactory.Open(port, baud);
}
catch (Exception ex)
{