vRIO: virtual RIO cockpit device emulator
Speaks the device side of the RIO serial protocol (per riojoy's PROTOCOL.md) on a COM port, behind an interactive replica of the profile editor's cockpit panel: click cells to press buttons/keys, drag the encoder gauges to move the five analog axes, and watch host-commanded lamp states (incl. flash modes) light the cells. Device behavior grounded in the real v4.2 firmware dump: version 4.2, 4-retry NAK budget ending in RESTART, and an optional emulation of the analog reply-wedge latch leak for exercising host recovery watchdogs. Verified: 33 unit tests, plus an interop harness driving RIOJoy's actual RioSerialLink against VRioDevice over an in-memory transport (version/check/analog/lamp/button/keypad/reset all round-trip). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
namespace VRio.Core.Device;
|
||||
|
||||
/// <summary>
|
||||
/// The RIO's digital address space, as RIOJoy's <c>iRIO</c> map sees it:
|
||||
/// 72 lamp-capable buttons at 0x00–0x47 (reported by index in
|
||||
/// ButtonPressed/Released), keypad 0 at 0x50–0x5F and keypad 1 at 0x60–0x6F
|
||||
/// (reported as KeyPressed/Released with a pad + key index; the PC adds the
|
||||
/// 0x50/0x60 offset). vRIO works in addresses and converts to wire form when
|
||||
/// sending.
|
||||
/// </summary>
|
||||
public static class RioAddressSpace
|
||||
{
|
||||
/// <summary>Number of digital button inputs / lamps (addresses 0x00–0x47).</summary>
|
||||
public const int LampCount = 72;
|
||||
|
||||
/// <summary>Address offset of keypad 0 (pad byte 0 on the wire).</summary>
|
||||
public const int Keypad0Base = 0x50;
|
||||
|
||||
/// <summary>Address offset of keypad 1 (pad byte 1 on the wire).</summary>
|
||||
public const int Keypad1Base = 0x60;
|
||||
|
||||
/// <summary>Highest valid address (keypad 1, key 0x0F).</summary>
|
||||
public const int MaxAddress = Keypad1Base + 0x0F; // 0x6F
|
||||
|
||||
/// <summary>True for a lamp-capable button address (0x00–0x47).</summary>
|
||||
public static bool IsButton(int address) => address is >= 0 and < LampCount;
|
||||
|
||||
/// <summary>True for a keypad address (0x50–0x5F or 0x60–0x6F).</summary>
|
||||
public static bool IsKeypad(int address) =>
|
||||
address is >= Keypad0Base and <= Keypad0Base + 0x0F
|
||||
or >= Keypad1Base and <= MaxAddress;
|
||||
|
||||
/// <summary>
|
||||
/// Convert a keypad address to its wire (pad, index) pair — the inverse of
|
||||
/// the PC's +0x50/+0x60 offsetting.
|
||||
/// </summary>
|
||||
public static (byte Pad, byte Index) ToKeypad(int address)
|
||||
{
|
||||
if (!IsKeypad(address))
|
||||
throw new ArgumentOutOfRangeException(nameof(address), $"0x{address:X2} is not a keypad address.");
|
||||
|
||||
return address >= Keypad1Base
|
||||
? ((byte)1, (byte)(address - Keypad1Base))
|
||||
: ((byte)0, (byte)(address - Keypad0Base));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The I/O boards a healthy cockpit reports, as (board number, name) —
|
||||
/// numbering from the legacy firmware's board table (riovjoy2.cpp
|
||||
/// <c>GetBoardName</c>). vRIO answers CheckRequest with one BoardOk
|
||||
/// CheckReply per entry.
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyList<(byte Number, string Name)> Boards = new (byte, string)[]
|
||||
{
|
||||
(0x00, "AuxLowerRight"),
|
||||
(0x08, "AuxLowerLeft"),
|
||||
(0x10, "Secondary1"),
|
||||
(0x11, "Secondary2"),
|
||||
(0x18, "AuxUpperCenter"),
|
||||
(0x19, "AuxUpperLeft"),
|
||||
(0x1A, "AuxUpperRight"),
|
||||
(0x20, "IntKeyPad"),
|
||||
(0x28, "ExtKeyPad"),
|
||||
(0x30, "Throttle"),
|
||||
(0x38, "JoyStick"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The five analog axes, indexed in AnalogReply payload order
|
||||
/// (riojoy/docs/PROTOCOL.md §4).
|
||||
/// </summary>
|
||||
public enum RioAxis
|
||||
{
|
||||
Throttle = 0,
|
||||
LeftPedal = 1,
|
||||
RightPedal = 2,
|
||||
JoystickY = 3,
|
||||
JoystickX = 4,
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
using VRio.Core.Protocol;
|
||||
|
||||
namespace VRio.Core.Device;
|
||||
|
||||
/// <summary>
|
||||
/// The virtual RIO board: a pure (transport-free) protocol state machine that
|
||||
/// behaves like the cockpit hardware on the wire. Feed it received bytes via
|
||||
/// <see cref="OnReceived"/>; everything it wants to transmit is raised through
|
||||
/// <see cref="Transmit"/>. The UI pokes it with <see cref="PressAddress"/>,
|
||||
/// <see cref="SetAxis"/>, etc., and listens for <see cref="LampChanged"/> to
|
||||
/// light its on-screen buttons.
|
||||
///
|
||||
/// <para>Wire behavior (mirroring what RIOJoy expects from the real board):</para>
|
||||
/// <list type="bullet">
|
||||
/// <item>ACKs every well-formed inbound packet, NAKs one with a bad checksum.</item>
|
||||
/// <item>CheckRequest → a BoardOk CheckReply per known board.</item>
|
||||
/// <item>VersionRequest → VersionReply with the configured firmware version
|
||||
/// (default 4.2, matching the real board's dumped EPROM).</item>
|
||||
/// <item>AnalogRequest → AnalogReply with the current five axis values.</item>
|
||||
/// <item>ResetRequest → re-zeroes the targeted axis (or all) like a
|
||||
/// recalibration, and reports it via <see cref="ResetReceived"/>.</item>
|
||||
/// <item>LampRequest → stores the lamp state and raises <see cref="LampChanged"/>.</item>
|
||||
/// <item>A NAK from the PC re-sends the last event packet up to 4 times, then
|
||||
/// gives up with a RESTART byte — the real v4.2 firmware's retry budget
|
||||
/// (riojoy/rio-firmware/RIOv4_2-ANALYSIS.md, counters $3173-$3175 limit 4,
|
||||
/// give-up at $D9D5 sends $FE).</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>With <see cref="EmulateReplyWedge"/> on, the give-up path also
|
||||
/// reproduces the v4.2 firmware's orphaned reply-in-progress latch ($2521):
|
||||
/// every subsequent AnalogRequest is silently dropped (still ACK'd — the RX
|
||||
/// path stays alive) until a host ResetRequest arrives, mirroring the
|
||||
/// mash-stress analog mute seen on real hardware. Useful for exercising
|
||||
/// RIOJoy's no-analog recovery watchdog.</para>
|
||||
///
|
||||
/// All members are thread-safe; events may fire on the caller's thread.
|
||||
/// </summary>
|
||||
public sealed class VRioDevice
|
||||
{
|
||||
// The real firmware retries a reply 4 times before giving up ($3173-$3175).
|
||||
private const int MaxResends = 4;
|
||||
|
||||
private readonly object _gate = new();
|
||||
private readonly PacketParser _parser = new();
|
||||
private readonly short[] _axes = new short[5];
|
||||
private readonly byte[] _lamps = new byte[RioAddressSpace.LampCount];
|
||||
|
||||
private byte[]? _lastEventPacket;
|
||||
private int _resends;
|
||||
private bool _analogMuted;
|
||||
|
||||
/// <summary>Firmware version reported by VersionReply (real boards run 4.2).</summary>
|
||||
public byte VersionMajor { get; set; } = 4;
|
||||
|
||||
/// <summary>Firmware version reported by VersionReply (real boards run 4.2).</summary>
|
||||
public byte VersionMinor { get; set; } = 2;
|
||||
|
||||
/// <summary>
|
||||
/// When true, retry exhaustion leaves the analog reply path wedged (the
|
||||
/// v4.2 latch-leak bug) until a host ResetRequest clears it.
|
||||
/// </summary>
|
||||
public bool EmulateReplyWedge { get; set; }
|
||||
|
||||
/// <summary>True while the analog reply path is wedged (see <see cref="EmulateReplyWedge"/>).</summary>
|
||||
public bool AnalogWedged
|
||||
{
|
||||
get { lock (_gate) return _analogMuted; }
|
||||
}
|
||||
|
||||
/// <summary>Bytes the device wants on the wire (already framed).</summary>
|
||||
public event Action<byte[]>? Transmit;
|
||||
|
||||
/// <summary>A lamp changed: (address 0x00–0x47, raw lamp-state byte).</summary>
|
||||
public event Action<int, byte>? LampChanged;
|
||||
|
||||
/// <summary>The PC asked for an axis reset/recalibration.</summary>
|
||||
public event Action<RioResetTarget>? ResetReceived;
|
||||
|
||||
/// <summary>Axis values changed (reset or local set) — refresh gauges.</summary>
|
||||
public event Action? AxesChanged;
|
||||
|
||||
/// <summary>Human-readable protocol log lines (analog polls excluded — see <see cref="AnalogRequests"/>).</summary>
|
||||
public event Action<string>? Logged;
|
||||
|
||||
/// <summary>Count of AnalogRequests served (RIOJoy polls ~18×/s; logging each would drown the log).</summary>
|
||||
public long AnalogRequests { get; private set; }
|
||||
|
||||
/// <summary>Count of AnalogRequests silently dropped while wedged.</summary>
|
||||
public long AnalogDropped { get; private set; }
|
||||
|
||||
/// <summary>Count of packets received with a bad checksum (NAK'd).</summary>
|
||||
public long BadChecksums { get; private set; }
|
||||
|
||||
// ---- Local (UI-facing) state ------------------------------------------
|
||||
|
||||
/// <summary>Current raw value of an axis.</summary>
|
||||
public short GetAxis(RioAxis axis)
|
||||
{
|
||||
lock (_gate) return _axes[(int)axis];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Move an axis. Values are clamped to the 14-bit signed range the wire
|
||||
/// can carry; the new value is returned by the next AnalogReply.
|
||||
/// </summary>
|
||||
public void SetAxis(RioAxis axis, int value)
|
||||
{
|
||||
short clamped = (short)Math.Max(AnalogCodec.Min, Math.Min(AnalogCodec.Max, value));
|
||||
lock (_gate) _axes[(int)axis] = clamped;
|
||||
AxesChanged?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>Current lamp state byte for a button address (0x00–0x47).</summary>
|
||||
public byte GetLamp(int address)
|
||||
{
|
||||
if (!RioAddressSpace.IsButton(address)) return 0;
|
||||
lock (_gate) return _lamps[address];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Locally darken every lamp (a fresh board powers up dark; the host
|
||||
/// re-lights what it wants). Callers should refresh their display.
|
||||
/// </summary>
|
||||
public void ClearLamps()
|
||||
{
|
||||
lock (_gate) Array.Clear(_lamps, 0, _lamps.Length);
|
||||
Logged?.Invoke("Local: all lamps cleared");
|
||||
}
|
||||
|
||||
// ---- Local inputs → wire events ---------------------------------------
|
||||
|
||||
/// <summary>Press the input at a RIO address (button or keypad key).</summary>
|
||||
public void PressAddress(int address) => SendInputEvent(address, pressed: true);
|
||||
|
||||
/// <summary>Release the input at a RIO address (button or keypad key).</summary>
|
||||
public void ReleaseAddress(int address) => SendInputEvent(address, pressed: false);
|
||||
|
||||
/// <summary>Announce a test-mode change (0 = exit test mode).</summary>
|
||||
public void SendTestMode(byte mode)
|
||||
{
|
||||
SendEvent(PacketBuilder.TestModeChange((byte)(mode & 0x7F)));
|
||||
Logged?.Invoke($"TX TestModeChange mode={mode}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Force the analog reply path into the wedged state right now (as if the
|
||||
/// retry give-up just leaked the latch) — a one-click way to watch the
|
||||
/// host's no-analog recovery kick in. A host ResetRequest clears it.
|
||||
/// </summary>
|
||||
public void WedgeAnalogNow()
|
||||
{
|
||||
lock (_gate) _analogMuted = true;
|
||||
Logged?.Invoke("Local: ANALOG WEDGED (v4.2 bug emulation) — waiting for a host reset");
|
||||
}
|
||||
|
||||
private void SendInputEvent(int address, bool pressed)
|
||||
{
|
||||
byte[] packet;
|
||||
if (RioAddressSpace.IsButton(address))
|
||||
{
|
||||
packet = pressed
|
||||
? PacketBuilder.ButtonPressed((byte)address)
|
||||
: PacketBuilder.ButtonReleased((byte)address);
|
||||
}
|
||||
else if (RioAddressSpace.IsKeypad(address))
|
||||
{
|
||||
(byte pad, byte index) = RioAddressSpace.ToKeypad(address);
|
||||
packet = pressed
|
||||
? PacketBuilder.KeyPressed(pad, index)
|
||||
: PacketBuilder.KeyReleased(pad, index);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(address), $"0x{address:X2} is not a RIO input address.");
|
||||
}
|
||||
|
||||
SendEvent(packet);
|
||||
Logged?.Invoke($"TX {(pressed ? "press" : "release")} 0x{address:X2}");
|
||||
}
|
||||
|
||||
// Event packets (button/key/test) are remembered so a NAK can re-send them.
|
||||
private void SendEvent(byte[] packet)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_lastEventPacket = packet;
|
||||
_resends = 0;
|
||||
}
|
||||
Transmit?.Invoke(packet);
|
||||
}
|
||||
|
||||
private void Send(byte[] packet) => Transmit?.Invoke(packet);
|
||||
|
||||
private void SendControl(RioControl control) => Transmit?.Invoke(new[] { (byte)control });
|
||||
|
||||
// ---- Wire → device -----------------------------------------------------
|
||||
|
||||
/// <summary>Feed bytes received from the PC.</summary>
|
||||
public void OnReceived(byte[] data, int count)
|
||||
{
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
bool produced;
|
||||
RioRxEvent ev;
|
||||
lock (_gate) produced = _parser.Feed(data[i], out ev);
|
||||
if (produced)
|
||||
HandleEvent(ev);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleEvent(RioRxEvent ev)
|
||||
{
|
||||
switch (ev.Kind)
|
||||
{
|
||||
case RioRxKind.Packet when !ev.ChecksumValid:
|
||||
BadChecksums++;
|
||||
SendControl(RioControl.Nak);
|
||||
Logged?.Invoke($"RX {ev.Packet} — bad checksum, NAK'd");
|
||||
break;
|
||||
|
||||
case RioRxKind.Packet:
|
||||
SendControl(RioControl.Ack);
|
||||
HandlePacket(ev.Packet);
|
||||
break;
|
||||
|
||||
case RioRxKind.ControlByte:
|
||||
HandleControl(ev.Byte);
|
||||
break;
|
||||
|
||||
case RioRxKind.FramingError:
|
||||
Logged?.Invoke($"RX framing error (0x{ev.Byte:X2} mid-packet) — resynced");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void HandlePacket(RioPacket packet)
|
||||
{
|
||||
byte[] p = packet.Payload;
|
||||
switch (packet.Command)
|
||||
{
|
||||
case RioCommand.CheckRequest:
|
||||
Logged?.Invoke("RX CheckRequest → all boards OK");
|
||||
foreach ((byte number, string _) in RioAddressSpace.Boards)
|
||||
Send(PacketBuilder.CheckReply(RioStatusType.BoardOk, number));
|
||||
break;
|
||||
|
||||
case RioCommand.VersionRequest:
|
||||
Logged?.Invoke($"RX VersionRequest → {VersionMajor}.{VersionMinor}");
|
||||
Send(PacketBuilder.VersionReply(VersionMajor, VersionMinor));
|
||||
break;
|
||||
|
||||
case RioCommand.AnalogRequest:
|
||||
short t, l, r, y, x;
|
||||
lock (_gate)
|
||||
{
|
||||
if (_analogMuted)
|
||||
{
|
||||
// The wedged v4.2 board drops the request in reply
|
||||
// generation ($D758) — the packet was still ACK'd above.
|
||||
AnalogDropped++;
|
||||
return;
|
||||
}
|
||||
AnalogRequests++;
|
||||
t = _axes[(int)RioAxis.Throttle];
|
||||
l = _axes[(int)RioAxis.LeftPedal];
|
||||
r = _axes[(int)RioAxis.RightPedal];
|
||||
y = _axes[(int)RioAxis.JoystickY];
|
||||
x = _axes[(int)RioAxis.JoystickX];
|
||||
}
|
||||
Send(PacketBuilder.AnalogReply(t, l, r, y, x));
|
||||
break;
|
||||
|
||||
case RioCommand.ResetRequest:
|
||||
var target = (RioResetTarget)p[0];
|
||||
ApplyReset(target);
|
||||
bool unwedged;
|
||||
lock (_gate)
|
||||
{
|
||||
// The host reset/init handler ($C686) is what clears the
|
||||
// real board's leaked reply latch — mirror that here.
|
||||
unwedged = _analogMuted;
|
||||
_analogMuted = false;
|
||||
}
|
||||
Logged?.Invoke($"RX ResetRequest {target} → re-zeroed" + (unwedged ? " (analog wedge cleared)" : ""));
|
||||
ResetReceived?.Invoke(target);
|
||||
break;
|
||||
|
||||
case RioCommand.LampRequest:
|
||||
int lamp = p[0];
|
||||
byte state = p[1];
|
||||
if (RioAddressSpace.IsButton(lamp))
|
||||
{
|
||||
lock (_gate) _lamps[lamp] = state;
|
||||
Logged?.Invoke($"RX Lamp 0x{lamp:X2} = 0x{state:X2} ({RioLampState.Describe(state)})");
|
||||
LampChanged?.Invoke(lamp, state);
|
||||
}
|
||||
else
|
||||
{
|
||||
Logged?.Invoke($"RX LampRequest for unknown lamp 0x{lamp:X2} — ignored");
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// A RIO→PC message arriving at the device end — echo it to the log.
|
||||
Logged?.Invoke($"RX unexpected {packet} — ignored");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// On the real board a reset re-references the encoder at its current
|
||||
// position; the emulator's equivalent is snapping the value back to zero.
|
||||
private void ApplyReset(RioResetTarget target)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
switch (target)
|
||||
{
|
||||
case RioResetTarget.Throttle: _axes[(int)RioAxis.Throttle] = 0; break;
|
||||
case RioResetTarget.LeftPedal: _axes[(int)RioAxis.LeftPedal] = 0; break;
|
||||
case RioResetTarget.RightPedal: _axes[(int)RioAxis.RightPedal] = 0; break;
|
||||
case RioResetTarget.VerticalJoystick: _axes[(int)RioAxis.JoystickY] = 0; break;
|
||||
case RioResetTarget.HorizontalJoystick: _axes[(int)RioAxis.JoystickX] = 0; break;
|
||||
default: Array.Clear(_axes, 0, _axes.Length); break; // general reset
|
||||
}
|
||||
}
|
||||
AxesChanged?.Invoke();
|
||||
}
|
||||
|
||||
private void HandleControl(byte b)
|
||||
{
|
||||
switch ((RioControl)b)
|
||||
{
|
||||
case RioControl.Ack:
|
||||
lock (_gate)
|
||||
{
|
||||
_lastEventPacket = null;
|
||||
_resends = 0;
|
||||
}
|
||||
break;
|
||||
|
||||
case RioControl.Nak:
|
||||
byte[]? resend = null;
|
||||
bool giveUp = false;
|
||||
lock (_gate)
|
||||
{
|
||||
if (_lastEventPacket is null)
|
||||
{
|
||||
// nothing pending; stray NAK
|
||||
}
|
||||
else if (_resends < MaxResends)
|
||||
{
|
||||
_resends++;
|
||||
resend = _lastEventPacket;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Retry budget exhausted: the real firmware sends RESTART
|
||||
// ($D9D5) and tears down — leaking its reply latch.
|
||||
_lastEventPacket = null;
|
||||
_resends = 0;
|
||||
giveUp = true;
|
||||
if (EmulateReplyWedge)
|
||||
_analogMuted = true;
|
||||
}
|
||||
}
|
||||
if (resend is not null)
|
||||
{
|
||||
Logged?.Invoke("RX NAK — re-sending last event");
|
||||
Transmit?.Invoke(resend);
|
||||
}
|
||||
else if (giveUp)
|
||||
{
|
||||
Logged?.Invoke(EmulateReplyWedge
|
||||
? "RX NAK — retries exhausted, RESTART sent; ANALOG WEDGED (v4.2 bug) until a host reset"
|
||||
: "RX NAK — retries exhausted, RESTART sent");
|
||||
SendControl(RioControl.Restart);
|
||||
}
|
||||
else
|
||||
{
|
||||
Logged?.Invoke("RX NAK — nothing to re-send (dropped)");
|
||||
}
|
||||
break;
|
||||
|
||||
case RioControl.Restart:
|
||||
Logged?.Invoke("RX RESTART");
|
||||
break;
|
||||
|
||||
case RioControl.Idle:
|
||||
break; // keep-alive noise
|
||||
|
||||
default:
|
||||
Logged?.Invoke($"RX stray byte 0x{b:X2}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
using System.IO.Ports;
|
||||
using VRio.Core.Protocol;
|
||||
|
||||
namespace VRio.Core.Device;
|
||||
|
||||
/// <summary>
|
||||
/// Pumps a <see cref="VRioDevice"/> over a real COM port at the RIO's
|
||||
/// 9600 8N1 settings. On a single PC, pair it with RIOJoy through a virtual
|
||||
/// null-modem (e.g. com0com): vRIO opens one end, RIOJoy the other.
|
||||
///
|
||||
/// <para>RIOJoy pulses DTR for 50 ms when it opens its end (the board-reset
|
||||
/// handshake); through a null modem that arrives here as a DSR blip, which is
|
||||
/// surfaced via <see cref="HostHandshake"/> so the UI can show that a host
|
||||
/// connected.</para>
|
||||
/// </summary>
|
||||
public sealed class VRioSerialService : IDisposable
|
||||
{
|
||||
/// <summary>RIO link bit rate (must match RIOJoy's transport).</summary>
|
||||
public const int BaudRate = 9600;
|
||||
|
||||
private readonly VRioDevice _device;
|
||||
private readonly object _writeGate = new();
|
||||
|
||||
private SerialPort? _port;
|
||||
private Thread? _reader;
|
||||
private volatile bool _running;
|
||||
|
||||
public VRioSerialService(VRioDevice device)
|
||||
{
|
||||
_device = device ?? throw new ArgumentNullException(nameof(device));
|
||||
_device.Transmit += Write;
|
||||
}
|
||||
|
||||
/// <summary>True while a COM port is open.</summary>
|
||||
public bool IsOpen => _port?.IsOpen == true;
|
||||
|
||||
/// <summary>The open port's name, or null.</summary>
|
||||
public string? PortName => _port?.PortName;
|
||||
|
||||
/// <summary>Raised after the port opens (true) or closes (false).</summary>
|
||||
public event Action<bool>? ConnectionChanged;
|
||||
|
||||
/// <summary>
|
||||
/// The host's DTR line changed (RIOJoy pulses it high for 50 ms when it
|
||||
/// opens the port — the board-reset handshake). The argument is the new
|
||||
/// line state as seen on our DSR pin.
|
||||
/// </summary>
|
||||
public event Action<bool>? HostHandshake;
|
||||
|
||||
/// <summary>Port-level log lines (open/close/errors).</summary>
|
||||
public event Action<string>? Logged;
|
||||
|
||||
/// <summary>Open <paramref name="portName"/> and start serving the device.</summary>
|
||||
public void Open(string portName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(portName))
|
||||
throw new ArgumentException("Port name is required.", nameof(portName));
|
||||
|
||||
Close();
|
||||
|
||||
var port = new SerialPort(portName, BaudRate, Parity.None, 8, StopBits.One)
|
||||
{
|
||||
Handshake = Handshake.None,
|
||||
// Finite read timeout so the reader thread can notice shutdown.
|
||||
ReadTimeout = 200,
|
||||
WriteTimeout = 2000,
|
||||
// Assert our modem lines: through a null modem the host sees DSR/CTS
|
||||
// high, i.e. "board present".
|
||||
DtrEnable = true,
|
||||
RtsEnable = true,
|
||||
};
|
||||
port.PinChanged += OnPinChanged;
|
||||
port.Open();
|
||||
|
||||
_port = port;
|
||||
_running = true;
|
||||
_reader = new Thread(ReadLoop) { IsBackground = true, Name = "vRIO serial reader" };
|
||||
_reader.Start();
|
||||
|
||||
Logged?.Invoke($"Opened {portName} @ {BaudRate} 8N1 — waiting for the host");
|
||||
ConnectionChanged?.Invoke(true);
|
||||
}
|
||||
|
||||
/// <summary>Close the port (idempotent).</summary>
|
||||
public void Close()
|
||||
{
|
||||
SerialPort? port = _port;
|
||||
if (port is null)
|
||||
return;
|
||||
|
||||
_running = false;
|
||||
_port = null;
|
||||
port.PinChanged -= OnPinChanged;
|
||||
try { port.Close(); }
|
||||
catch (IOException) { }
|
||||
port.Dispose();
|
||||
|
||||
_reader?.Join(1000);
|
||||
_reader = null;
|
||||
|
||||
Logged?.Invoke("Port closed");
|
||||
ConnectionChanged?.Invoke(false);
|
||||
}
|
||||
|
||||
private void OnPinChanged(object sender, SerialPinChangedEventArgs e)
|
||||
{
|
||||
// RIOJoy raises then drops DTR on open; either edge means a host is there.
|
||||
if (e.EventType != SerialPinChange.DsrChanged)
|
||||
return;
|
||||
|
||||
bool high = false;
|
||||
try { high = _port?.DsrHolding ?? false; }
|
||||
catch (Exception ex) when (ex is IOException or InvalidOperationException) { }
|
||||
HostHandshake?.Invoke(high);
|
||||
}
|
||||
|
||||
private void ReadLoop()
|
||||
{
|
||||
var buffer = new byte[256];
|
||||
while (_running)
|
||||
{
|
||||
SerialPort? port = _port;
|
||||
if (port is null)
|
||||
return;
|
||||
|
||||
int n;
|
||||
try
|
||||
{
|
||||
n = port.Read(buffer, 0, buffer.Length);
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
continue; // just a poll tick; check _running again
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidOperationException or OperationCanceledException)
|
||||
{
|
||||
if (_running)
|
||||
Logged?.Invoke($"Port error: {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (n > 0)
|
||||
_device.OnReceived(buffer, n);
|
||||
}
|
||||
}
|
||||
|
||||
private void Write(byte[] data)
|
||||
{
|
||||
SerialPort? port = _port;
|
||||
if (port is null || !port.IsOpen)
|
||||
return; // device poked while offline — drop silently
|
||||
|
||||
try
|
||||
{
|
||||
lock (_writeGate)
|
||||
port.Write(data, 0, data.Length);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidOperationException or TimeoutException)
|
||||
{
|
||||
Logged?.Invoke($"Write failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_device.Transmit -= Write;
|
||||
Close();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user