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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
using VRio.Core.Device;
|
||||
|
||||
namespace VRio.Core.Panel;
|
||||
|
||||
/// <summary>Shape of a panel group.</summary>
|
||||
public enum PanelGroupKind
|
||||
{
|
||||
/// <summary>A 4×2 MFD button cluster (lamp buttons).</summary>
|
||||
Mfd,
|
||||
|
||||
/// <summary>A vertical 1×8 board column (lamp buttons).</summary>
|
||||
Column,
|
||||
|
||||
/// <summary>A 4×4 keypad (no lamps).</summary>
|
||||
Keypad,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One functional group on the cockpit control panel: a titled block of address
|
||||
/// buttons laid out row-major in <see cref="Rows"/> × <see cref="Cols"/>.
|
||||
/// <see cref="OriginCol"/>/<see cref="OriginRow"/> place the block on the panel
|
||||
/// in button-cell units.
|
||||
/// </summary>
|
||||
public sealed record PanelGroup(
|
||||
string Title,
|
||||
PanelGroupKind Kind,
|
||||
int Cols,
|
||||
int Rows,
|
||||
bool LampCapable,
|
||||
int OriginCol,
|
||||
int OriginRow,
|
||||
IReadOnlyList<int?> Addresses);
|
||||
|
||||
/// <summary>One address button placed on the panel (absolute cell coordinates).</summary>
|
||||
public sealed record PanelButton(int Address, PanelGroup Group, int Col, int Row, bool LampCapable);
|
||||
|
||||
/// <summary>
|
||||
/// The cockpit control panel's logical layout — the same functional grouping
|
||||
/// RIOJoy's profile editor draws (its <c>CockpitPanel</c>, derived from the
|
||||
/// original Win32 RIO control-panel mockup): five MFD clusters, four board
|
||||
/// columns, and the two later-added 4×4 keypads. Every RIO address
|
||||
/// 0x00–0x47 / 0x50–0x6F is placed exactly once. Keeping the byte-for-byte same
|
||||
/// layout means a button on vRIO sits where the same button sits in RIOJoy's
|
||||
/// editor — pressing one lights the other.
|
||||
/// </summary>
|
||||
public static class CockpitLayout
|
||||
{
|
||||
// Helper: 4×2 MFD address block, top row then bottom row (addresses descend).
|
||||
private static int?[] Mfd(int hi) => new int?[]
|
||||
{
|
||||
hi, hi - 1, hi - 2, hi - 3,
|
||||
hi - 4, hi - 5, hi - 6, hi - 7,
|
||||
};
|
||||
|
||||
// Helper: vertical 1×8 column from a base address upward.
|
||||
private static int?[] Col8(int baseAddr) => new int?[]
|
||||
{
|
||||
baseAddr, baseAddr + 1, baseAddr + 2, baseAddr + 3,
|
||||
baseAddr + 4, baseAddr + 5, baseAddr + 6, baseAddr + 7,
|
||||
};
|
||||
|
||||
// Helper: 4×4 keypad. Physical keys 1 2 3 C / 4 5 6 D / 7 8 9 E / A 0 B F map
|
||||
// to address = base + that hex digit.
|
||||
private static int?[] Keypad(int baseAddr)
|
||||
{
|
||||
int[] digits = { 0x1, 0x2, 0x3, 0xC, 0x4, 0x5, 0x6, 0xD, 0x7, 0x8, 0x9, 0xE, 0xA, 0x0, 0xB, 0xF };
|
||||
var a = new int?[16];
|
||||
for (int i = 0; i < 16; i++) a[i] = baseAddr + digits[i];
|
||||
return a;
|
||||
}
|
||||
|
||||
/// <summary>All panel groups, positioned for rendering.</summary>
|
||||
public static IReadOnlyList<PanelGroup> Groups { get; } = new[]
|
||||
{
|
||||
// Upper MFD row.
|
||||
new PanelGroup("Upper Left MFD", PanelGroupKind.Mfd, 4, 2, true, 0, 1, Mfd(0x2F)),
|
||||
new PanelGroup("Upper Middle MFD", PanelGroupKind.Mfd, 4, 2, true, 4, 1, Mfd(0x27)),
|
||||
new PanelGroup("Upper Right MFD", PanelGroupKind.Mfd, 4, 2, true, 8, 1, Mfd(0x37)),
|
||||
|
||||
// Lower MFD row.
|
||||
new PanelGroup("Lower Left MFD", PanelGroupKind.Mfd, 4, 2, true, 0, 5, Mfd(0x0F)),
|
||||
new PanelGroup("Lower Right MFD", PanelGroupKind.Mfd, 4, 2, true, 8, 5, Mfd(0x07)),
|
||||
|
||||
// Board columns.
|
||||
new PanelGroup("Throttle", PanelGroupKind.Column, 1, 8, true, 0, 9, Col8(0x38)),
|
||||
new PanelGroup("Secondary", PanelGroupKind.Column, 1, 8, true, 4, 9, Col8(0x10)),
|
||||
new PanelGroup("Screen", PanelGroupKind.Column, 1, 8, true, 6, 9, Col8(0x18)),
|
||||
new PanelGroup("Joystick", PanelGroupKind.Column, 1, 8, true, 11, 9, Col8(0x40)),
|
||||
|
||||
// Keypads (no lamps): internal in the middle, external between the
|
||||
// Secondary/Screen columns and the Joystick column.
|
||||
new PanelGroup("Internal Keypad", PanelGroupKind.Keypad, 4, 4, false, 4, 4, Keypad(0x50)),
|
||||
new PanelGroup("External Keypad", PanelGroupKind.Keypad, 4, 4, false, 7, 9, Keypad(0x60)),
|
||||
};
|
||||
|
||||
/// <summary>Flatten the groups to positioned <see cref="PanelButton"/>s (absolute cells).</summary>
|
||||
public static IReadOnlyList<PanelButton> Buttons()
|
||||
{
|
||||
var buttons = new List<PanelButton>();
|
||||
foreach (PanelGroup g in Groups)
|
||||
{
|
||||
for (int i = 0; i < g.Addresses.Count; i++)
|
||||
{
|
||||
if (g.Addresses[i] is not { } addr) continue;
|
||||
int localCol = i % g.Cols;
|
||||
int localRow = i / g.Cols;
|
||||
// +1 row leaves space under the title.
|
||||
buttons.Add(new PanelButton(addr, g, g.OriginCol + localCol, g.OriginRow + 1 + localRow, g.LampCapable));
|
||||
}
|
||||
}
|
||||
return buttons;
|
||||
}
|
||||
|
||||
// Named physical controls (from the Win32RIO control-panel mockup); every
|
||||
// other lamp button is known only by its hex address.
|
||||
private static readonly Dictionary<int, string> Named = new()
|
||||
{
|
||||
[0x3D] = "Panic",
|
||||
[0x3F] = "Throttle",
|
||||
[0x40] = "Main",
|
||||
[0x41] = "Hat Back",
|
||||
[0x42] = "Hat Up",
|
||||
[0x43] = "Hat Right",
|
||||
[0x44] = "Hat Left",
|
||||
[0x45] = "Pinky",
|
||||
[0x46] = "Middle",
|
||||
[0x47] = "Upper",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The label printed on the physical control: keypad keys carry their hex
|
||||
/// digit, a few controls have names, the rest is null (show the address).
|
||||
/// </summary>
|
||||
public static string? PhysicalLabel(int address)
|
||||
{
|
||||
if (RioAddressSpace.IsKeypad(address))
|
||||
return (address & 0x0F).ToString("X1");
|
||||
return Named.TryGetValue(address, out string? name) ? name : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
namespace VRio.Core.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// Builds outbound packets: <c>[command][payload…][checksum]</c>. Same framing
|
||||
/// as RIOJoy's builder, but the convenience methods here cover the RIO → PC
|
||||
/// messages, because vRIO <em>is</em> the device.
|
||||
/// </summary>
|
||||
public static class PacketBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Build a wire-ready packet for <paramref name="command"/>. The payload
|
||||
/// length must match the command's entry in the length table.
|
||||
/// </summary>
|
||||
public static byte[] Build(RioCommand command, ReadOnlySpan<byte> payload)
|
||||
{
|
||||
int expected = RioCommandTable.PayloadLength(command);
|
||||
if (payload.Length != expected)
|
||||
throw new ArgumentException(
|
||||
$"{command} expects a {expected}-byte payload, got {payload.Length}.",
|
||||
nameof(payload));
|
||||
|
||||
var packet = new byte[1 + expected + 1];
|
||||
packet[0] = (byte)command;
|
||||
payload.CopyTo(packet.AsSpan(1));
|
||||
packet[packet.Length - 1] = RioChecksum.Compute(packet.AsSpan(0, 1 + expected));
|
||||
return packet;
|
||||
}
|
||||
|
||||
/// <summary>Build a zero-payload command packet.</summary>
|
||||
public static byte[] Build(RioCommand command) => Build(command, ReadOnlySpan<byte>.Empty);
|
||||
|
||||
// --- Convenience builders for the RIO → PC messages ----------------------
|
||||
|
||||
public static byte[] CheckReply(RioStatusType type, byte number) =>
|
||||
Build(RioCommand.CheckReply, stackalloc byte[] { (byte)type, number });
|
||||
|
||||
public static byte[] VersionReply(byte major, byte minor) =>
|
||||
Build(RioCommand.VersionReply, stackalloc byte[] { major, minor });
|
||||
|
||||
/// <summary>
|
||||
/// AnalogReply: 5 axes × (low, high) in the order throttle, left pedal,
|
||||
/// right pedal, joystick Y, joystick X. Each axis is a 14-bit signed value
|
||||
/// split into two 7-bit bytes (see <see cref="AnalogCodec"/>).
|
||||
/// </summary>
|
||||
public static byte[] AnalogReply(short throttle, short leftPedal, short rightPedal, short joystickY, short joystickX)
|
||||
{
|
||||
Span<byte> payload = stackalloc byte[10];
|
||||
AnalogCodec.Split(throttle, out payload[0], out payload[1]);
|
||||
AnalogCodec.Split(leftPedal, out payload[2], out payload[3]);
|
||||
AnalogCodec.Split(rightPedal, out payload[4], out payload[5]);
|
||||
AnalogCodec.Split(joystickY, out payload[6], out payload[7]);
|
||||
AnalogCodec.Split(joystickX, out payload[8], out payload[9]);
|
||||
return Build(RioCommand.AnalogReply, payload);
|
||||
}
|
||||
|
||||
public static byte[] ButtonPressed(byte index) =>
|
||||
Build(RioCommand.ButtonPressed, stackalloc byte[] { index });
|
||||
|
||||
public static byte[] ButtonReleased(byte index) =>
|
||||
Build(RioCommand.ButtonReleased, stackalloc byte[] { index });
|
||||
|
||||
public static byte[] KeyPressed(byte pad, byte index) =>
|
||||
Build(RioCommand.KeyPressed, stackalloc byte[] { pad, index });
|
||||
|
||||
public static byte[] KeyReleased(byte pad, byte index) =>
|
||||
Build(RioCommand.KeyReleased, stackalloc byte[] { pad, index });
|
||||
|
||||
public static byte[] TestModeChange(byte mode) =>
|
||||
Build(RioCommand.TestModeChange, stackalloc byte[] { mode });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Packs/unpacks the 14-bit signed axis values carried by AnalogReply: two
|
||||
/// 7-bit bytes, low then high; bit 13 is the sign (riojoy/docs/PROTOCOL.md §4).
|
||||
/// </summary>
|
||||
public static class AnalogCodec
|
||||
{
|
||||
/// <summary>Smallest representable axis value (14-bit signed).</summary>
|
||||
public const short Min = -8192;
|
||||
|
||||
/// <summary>Largest representable axis value (14-bit signed).</summary>
|
||||
public const short Max = 8191;
|
||||
|
||||
/// <summary>Split an axis value into its (low, high) 7-bit wire bytes.</summary>
|
||||
public static void Split(short value, out byte low, out byte high)
|
||||
{
|
||||
if (value is < Min or > Max)
|
||||
throw new ArgumentOutOfRangeException(nameof(value), $"Axis value must be {Min}..{Max}.");
|
||||
|
||||
int raw = value & 0x3FFF; // 14-bit two's complement
|
||||
low = (byte)(raw & 0x7F);
|
||||
high = (byte)((raw >> 7) & 0x7F);
|
||||
}
|
||||
|
||||
/// <summary>Recombine (low, high) wire bytes into the signed axis value.</summary>
|
||||
public static short Combine(byte low, byte high)
|
||||
{
|
||||
int raw = (low & 0x7F) | (high << 7); // 14 bits
|
||||
if ((raw & 0x2000) != 0) // bit 13 set → negative
|
||||
raw |= ~0x3FFF; // sign-extend into the high bits
|
||||
return (short)raw;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
namespace VRio.Core.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// Streaming receive-side framing state machine, fed raw bytes from the serial
|
||||
/// port. Identical framing rules to RIOJoy's parser (riojoy/docs/PROTOCOL.md §2)
|
||||
/// — vRIO just feeds it the opposite direction of traffic (PC → RIO commands):
|
||||
/// <list type="bullet">
|
||||
/// <item>Outside a packet, a byte in the command range starts a packet of
|
||||
/// <c>1 + payloadLen + 1</c> bytes (command + payload + checksum).</item>
|
||||
/// <item>Outside a packet, any other byte is a control byte (ACK/NAK/…).</item>
|
||||
/// <item>Mid-packet, a byte with the high bit set aborts the packet and
|
||||
/// resynchronizes; that byte is discarded.</item>
|
||||
/// <item>The final byte completes the packet with a real checksum compare.</item>
|
||||
/// </list>
|
||||
/// Not thread-safe; drive it from one reader.
|
||||
/// </summary>
|
||||
public sealed class PacketParser
|
||||
{
|
||||
// Max framed packet = command(1) + AnalogReply payload(10) + checksum(1) = 12.
|
||||
private const int MaxPacketBytes = 16;
|
||||
|
||||
private readonly byte[] _buffer = new byte[MaxPacketBytes];
|
||||
|
||||
// Bytes still expected to complete the current packet (0 = not in a packet).
|
||||
private int _remaining;
|
||||
|
||||
// Count of bytes stored in _buffer for the current packet (incl. command).
|
||||
private int _count;
|
||||
|
||||
/// <summary>True while a packet is partially received.</summary>
|
||||
public bool InPacket => _remaining != 0;
|
||||
|
||||
/// <summary>Discard any in-progress packet and reset to the idle state.</summary>
|
||||
public void Reset()
|
||||
{
|
||||
_remaining = 0;
|
||||
_count = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Feed one received byte. Returns <see langword="true"/> and sets
|
||||
/// <paramref name="ev"/> when this byte produced an event; otherwise the byte
|
||||
/// was consumed into an in-progress packet.
|
||||
/// </summary>
|
||||
public bool Feed(byte ch, out RioRxEvent ev)
|
||||
{
|
||||
if (_remaining != 0)
|
||||
{
|
||||
// Mid-packet. A high-bit byte is a framing error → abort and resync.
|
||||
if ((ch & 0x80) != 0)
|
||||
{
|
||||
Reset();
|
||||
ev = RioRxEvent.ForFramingError(ch);
|
||||
return true;
|
||||
}
|
||||
|
||||
_buffer[_count++] = ch;
|
||||
_remaining--;
|
||||
|
||||
if (_remaining == 0)
|
||||
{
|
||||
ev = CompletePacket();
|
||||
return true;
|
||||
}
|
||||
|
||||
ev = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Not in a packet.
|
||||
if (RioCommandTable.IsCommand(ch))
|
||||
{
|
||||
_count = 0;
|
||||
_buffer[_count++] = ch;
|
||||
_remaining = RioCommandTable.PayloadLength(ch) + 1; // + checksum byte
|
||||
ev = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
// A byte outside framing: control character (ACK/NAK/RESTART/IDLE) or garbage.
|
||||
ev = RioRxEvent.ForControl(ch);
|
||||
return true;
|
||||
}
|
||||
|
||||
private RioRxEvent CompletePacket()
|
||||
{
|
||||
// _buffer = [command][payload…][checksum]; _count includes all of them.
|
||||
int bodyLen = _count - 1; // command + payload (exclude checksum)
|
||||
byte receivedChecksum = _buffer[bodyLen];
|
||||
byte computed = RioChecksum.Compute(_buffer.AsSpan(0, bodyLen));
|
||||
bool checksumValid = computed == receivedChecksum;
|
||||
|
||||
var command = (RioCommand)_buffer[0];
|
||||
var payload = _buffer.AsSpan(1, bodyLen - 1).ToArray(); // copy out; buffer is reused
|
||||
var packet = new RioPacket(command, payload);
|
||||
|
||||
Reset();
|
||||
return RioRxEvent.ForPacket(packet, checksumValid);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace VRio.Core.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// The RIO packet checksum: the low 7 bits of the sum of the low 7 bits of
|
||||
/// every byte in the command+payload (the checksum byte itself excluded).
|
||||
/// Same algorithm as RIOJoy / the legacy firmware (riojoy/docs/PROTOCOL.md §2).
|
||||
/// </summary>
|
||||
public static class RioChecksum
|
||||
{
|
||||
/// <summary>
|
||||
/// Compute the 7-bit checksum over <paramref name="commandAndPayload"/>
|
||||
/// (the command byte followed by its payload bytes — not the checksum byte).
|
||||
/// </summary>
|
||||
public static byte Compute(ReadOnlySpan<byte> commandAndPayload)
|
||||
{
|
||||
byte sum = 0;
|
||||
foreach (byte b in commandAndPayload)
|
||||
sum += (byte)(b & 0x7F);
|
||||
|
||||
return (byte)(sum & 0x7F);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
namespace VRio.Core.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// RIO message commands. The command byte always has its high bit set; payload
|
||||
/// bytes are 7-bit. Wire contract shared with RIOJoy (riojoy/docs/PROTOCOL.md §3),
|
||||
/// originally reverse-engineered from the legacy riovjoy2.cpp. vRIO sits on the
|
||||
/// <em>device</em> side of this table: it receives the PC→RIO commands and sends
|
||||
/// the RIO→PC ones.
|
||||
/// </summary>
|
||||
public enum RioCommand : byte
|
||||
{
|
||||
// PC → RIO (vRIO receives these)
|
||||
CheckRequest = 0x80,
|
||||
VersionRequest = 0x81,
|
||||
AnalogRequest = 0x82,
|
||||
ResetRequest = 0x83,
|
||||
LampRequest = 0x84,
|
||||
|
||||
// RIO → PC (vRIO sends these)
|
||||
CheckReply = 0x85,
|
||||
VersionReply = 0x86,
|
||||
AnalogReply = 0x87,
|
||||
ButtonPressed = 0x88,
|
||||
ButtonReleased = 0x89,
|
||||
KeyPressed = 0x8A,
|
||||
KeyReleased = 0x8B,
|
||||
TestModeChange = 0x8C,
|
||||
}
|
||||
|
||||
/// <summary>Single-byte control characters that live outside packet framing.</summary>
|
||||
public enum RioControl : byte
|
||||
{
|
||||
/// <summary>Packet accepted.</summary>
|
||||
Ack = 0xFC,
|
||||
|
||||
/// <summary>Packet rejected; resend.</summary>
|
||||
Nak = 0xFD,
|
||||
|
||||
/// <summary>Restart; also used as an in-payload "invalid" sentinel.</summary>
|
||||
Restart = 0xFE,
|
||||
|
||||
/// <summary>Idle.</summary>
|
||||
Idle = 0xFF,
|
||||
}
|
||||
|
||||
/// <summary>Reset targets for <see cref="RioCommand.ResetRequest"/>.</summary>
|
||||
public enum RioResetTarget : byte
|
||||
{
|
||||
All = 0,
|
||||
Throttle = 1,
|
||||
LeftPedal = 2,
|
||||
RightPedal = 3,
|
||||
VerticalJoystick = 4, // Y
|
||||
HorizontalJoystick = 5, // X
|
||||
}
|
||||
|
||||
/// <summary>The <c>statusType</c> field of <see cref="RioCommand.CheckReply"/>.</summary>
|
||||
public enum RioStatusType : byte
|
||||
{
|
||||
BoardOk = 0,
|
||||
BoardMissing = 1,
|
||||
BoardBad = 2,
|
||||
LampBad = 3,
|
||||
RestartCount = 4,
|
||||
AbandonCount = 5,
|
||||
FullBufferCount = 6,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-command payload lengths (bytes between the command byte and the checksum
|
||||
/// byte). Must match RIOJoy's table exactly or the two ends lose framing.
|
||||
/// </summary>
|
||||
public static class RioCommandTable
|
||||
{
|
||||
/// <summary>Lowest command byte value (<see cref="RioCommand.CheckRequest"/>).</summary>
|
||||
public const byte Base = (byte)RioCommand.CheckRequest;
|
||||
|
||||
// Indexed by (command - Base). Order must match RioCommand.
|
||||
private static readonly byte[] PayloadLengths =
|
||||
{
|
||||
0, // CheckRequest
|
||||
0, // VersionRequest
|
||||
0, // AnalogRequest
|
||||
1, // ResetRequest
|
||||
2, // LampRequest
|
||||
2, // CheckReply
|
||||
2, // VersionReply
|
||||
10, // AnalogReply
|
||||
1, // ButtonPressed
|
||||
1, // ButtonReleased
|
||||
2, // KeyPressed
|
||||
2, // KeyReleased
|
||||
1, // TestModeChange
|
||||
};
|
||||
|
||||
/// <summary>Number of defined commands.</summary>
|
||||
public static int Count => PayloadLengths.Length;
|
||||
|
||||
/// <summary>Highest valid command byte value (inclusive).</summary>
|
||||
public static byte Max => (byte)(Base + Count - 1);
|
||||
|
||||
/// <summary>True if <paramref name="commandByte"/> is a known command.</summary>
|
||||
public static bool IsCommand(byte commandByte) =>
|
||||
commandByte >= Base && commandByte <= Max;
|
||||
|
||||
/// <summary>Payload length for a command byte (guard with <see cref="IsCommand"/>).</summary>
|
||||
public static int PayloadLength(byte commandByte)
|
||||
{
|
||||
if (!IsCommand(commandByte))
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(commandByte),
|
||||
$"0x{commandByte:X2} is not a RIO command (valid 0x{Base:X2}..0x{Max:X2}).");
|
||||
|
||||
return PayloadLengths[commandByte - Base];
|
||||
}
|
||||
|
||||
/// <summary>Payload length for a <see cref="RioCommand"/>.</summary>
|
||||
public static int PayloadLength(RioCommand command) => PayloadLength((byte)command);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
namespace VRio.Core.Protocol;
|
||||
|
||||
/// <summary>Flash mode for a lighted button (lamp-state bits 0–1).</summary>
|
||||
public enum LampFlash : byte
|
||||
{
|
||||
Solid = 0,
|
||||
FlashSlow = 1,
|
||||
FlashMed = 2,
|
||||
FlashFast = 3,
|
||||
}
|
||||
|
||||
/// <summary>Brightness level decoded from a lamp-state byte.</summary>
|
||||
public enum LampBrightness
|
||||
{
|
||||
Off,
|
||||
Dim,
|
||||
Bright,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The <c>state</c> byte of a <see cref="RioCommand.LampRequest"/>: flash bits
|
||||
/// 0–1 plus two brightness fields (bits 2–3 and 4–5). vRIO decodes it to shade
|
||||
/// its on-screen lamps the way the physical board would light them.
|
||||
/// </summary>
|
||||
public static class RioLampState
|
||||
{
|
||||
/// <summary>Lamp off (SolidOff, 0x00).</summary>
|
||||
public const byte SolidOff = 0x00;
|
||||
|
||||
/// <summary>Dim solid (SolidDim, 0x14) — RIOJoy's idle state for mapped lamps.</summary>
|
||||
public const byte SolidDim = 0x14;
|
||||
|
||||
/// <summary>Bright solid (SolidBright, 0x3C) — RIOJoy's pressed state.</summary>
|
||||
public const byte SolidBright = 0x3C;
|
||||
|
||||
/// <summary>Flash mode from a lamp-state byte.</summary>
|
||||
public static LampFlash Flash(byte state) => (LampFlash)(state & 0x03);
|
||||
|
||||
/// <summary>
|
||||
/// Effective brightness from a lamp-state byte: the brighter of the two
|
||||
/// brightness fields (field 1 = bits 2–3: 0x04 dim / 0x0C bright; field 2 =
|
||||
/// bits 4–5: 0x10 dim / 0x30 bright).
|
||||
/// </summary>
|
||||
public static LampBrightness Brightness(byte state)
|
||||
{
|
||||
int f1 = state & 0x0C;
|
||||
int f2 = state & 0x30;
|
||||
bool bright = f1 == 0x0C || f2 == 0x30;
|
||||
bool dim = f1 != 0 || f2 != 0;
|
||||
return bright ? LampBrightness.Bright : dim ? LampBrightness.Dim : LampBrightness.Off;
|
||||
}
|
||||
|
||||
/// <summary>Human-readable description, e.g. <c>"Bright"</c> or <c>"Dim flash-fast"</c>.</summary>
|
||||
public static string Describe(byte state)
|
||||
{
|
||||
LampBrightness b = Brightness(state);
|
||||
LampFlash f = Flash(state);
|
||||
return f == LampFlash.Solid ? b.ToString() : $"{b} {f}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
namespace VRio.Core.Protocol;
|
||||
|
||||
/// <summary>One framed RIO message: the command plus its (checksum-stripped) payload.</summary>
|
||||
public readonly struct RioPacket
|
||||
{
|
||||
public RioCommand Command { get; }
|
||||
public byte[] Payload { get; }
|
||||
|
||||
public RioPacket(RioCommand command, byte[] payload)
|
||||
{
|
||||
Command = command;
|
||||
Payload = payload ?? Array.Empty<byte>();
|
||||
}
|
||||
|
||||
public override string ToString() =>
|
||||
Payload.Length == 0
|
||||
? Command.ToString()
|
||||
: $"{Command} [{string.Join(" ", Payload.Select(b => b.ToString("X2")))}]";
|
||||
}
|
||||
|
||||
/// <summary>What a fed byte produced (see <see cref="PacketParser"/>).</summary>
|
||||
public enum RioRxKind
|
||||
{
|
||||
/// <summary>A complete framed packet.</summary>
|
||||
Packet,
|
||||
|
||||
/// <summary>A single byte outside framing: ACK/NAK/RESTART/IDLE or garbage.</summary>
|
||||
ControlByte,
|
||||
|
||||
/// <summary>A high-bit byte arrived mid-packet; the packet was abandoned.</summary>
|
||||
FramingError,
|
||||
}
|
||||
|
||||
/// <summary>A receive event emitted by <see cref="PacketParser"/>.</summary>
|
||||
public readonly struct RioRxEvent
|
||||
{
|
||||
public RioRxKind Kind { get; }
|
||||
public RioPacket Packet { get; }
|
||||
public byte Byte { get; }
|
||||
public bool ChecksumValid { get; }
|
||||
|
||||
private RioRxEvent(RioRxKind kind, RioPacket packet, byte b, bool checksumValid)
|
||||
{
|
||||
Kind = kind;
|
||||
Packet = packet;
|
||||
Byte = b;
|
||||
ChecksumValid = checksumValid;
|
||||
}
|
||||
|
||||
public static RioRxEvent ForPacket(RioPacket packet, bool checksumValid) =>
|
||||
new(RioRxKind.Packet, packet, 0, checksumValid);
|
||||
|
||||
public static RioRxEvent ForControl(byte b) =>
|
||||
new(RioRxKind.ControlByte, default, b, false);
|
||||
|
||||
public static RioRxEvent ForFramingError(byte b) =>
|
||||
new(RioRxKind.FramingError, default, b, false);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- net48 so it runs in-box on the cabinet PCs, same as RIOJoy. Uses
|
||||
System.IO.Ports from the framework BCL; Span/records/init come from
|
||||
System.Memory + PolySharp. -->
|
||||
<TargetFramework>net48</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Memory" Version="4.5.5" />
|
||||
<PackageReference Include="Microsoft.Bcl.HashCode" Version="1.1.1" />
|
||||
<PackageReference Include="PolySharp" Version="1.14.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user