From 9cca7c77bd224ee5c41d79ef5f5893fad4552398 Mon Sep 17 00:00:00 2001 From: Cyd Date: Thu, 30 Jul 2026 10:14:13 -0500 Subject: [PATCH] serial: pipe:vrio named-pipe transport, no com0com needed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NamedPipeTransport connects as a client to vRIO's \\.\pipe\vrio (the DOSBox-X fork's role) and speaks the shared typed-frame contract (PipeFraming: 0x00 data / 0x01 modem lines, null-modem crossed). The COM path's DTR reset pulse is replayed in-band on connect. Peer disconnects and framing violations surface as the 0-byte transport-closed read the link already understands. RioTransportFactory routes endpoint strings — pipe:name (vRIO's own picker syntax) to the pipe transport, everything else to SerialPortTransport — and is wired into RioCoordinator and all three RioSerialMonitor modes, so profiles (RioComPort/DefaultRioComPort) and the bench tools take pipe endpoints anywhere a COM name went. Gotcha baked into the design: named pipes here have 0-byte buffers, so a write blocks until the peer reads it, and vRIO also writes its lines frame before reading — the on-connect pulse frames are therefore queued as overlapped writes (pipe writes drain in issue order, preserving the edge positions) instead of blocking the constructor into a mutual write-first deadlock. Verified end-to-end against the real VRioDevice + VRioPipeService over \\.\pipe\vrio: version 4.2 + check replies, 137 analog polls, lamp commands ACKed, zero framing errors. 322 tests green, both flavors. Co-Authored-By: Claude Fable 5 --- README.md | 13 + src/RioJoy.Core/Profiles/AppConfig.cs | 6 +- src/RioJoy.Core/Profiles/RioProfile.cs | 2 +- src/RioJoy.Core/Serial/NamedPipeTransport.cs | 174 +++++++++++ src/RioJoy.Core/Serial/PipeFraming.cs | 160 ++++++++++ src/RioJoy.Core/Serial/RioTransportFactory.cs | 33 +++ src/RioJoy.Tray/RioCoordinator.cs | 2 +- .../Serial/NamedPipeTransportTests.cs | 275 ++++++++++++++++++ tools/RioSerialMonitor/E0Test.cs | 6 +- tools/RioSerialMonitor/MashTest.cs | 2 +- tools/RioSerialMonitor/Program.cs | 6 +- 11 files changed, 670 insertions(+), 9 deletions(-) create mode 100644 src/RioJoy.Core/Serial/NamedPipeTransport.cs create mode 100644 src/RioJoy.Core/Serial/PipeFraming.cs create mode 100644 src/RioJoy.Core/Serial/RioTransportFactory.cs create mode 100644 tests/RioJoy.Core.Tests/Serial/NamedPipeTransportTests.cs 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) {