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,290 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO.Ports;
|
||||
using VRio.Core.Device;
|
||||
|
||||
namespace VRio.App;
|
||||
|
||||
/// <summary>
|
||||
/// vRIO main window: the interactive cockpit panel on the left (the same
|
||||
/// functional map RIOJoy's profile editor shows) and a control strip on the
|
||||
/// right — COM port, device settings, and a live wire log. Open the COM port,
|
||||
/// point RIOJoy at the other end of the null-modem pair, and every click here
|
||||
/// arrives at RIOJoy exactly as if the physical cockpit sent it.
|
||||
/// </summary>
|
||||
internal sealed class MainForm : Form
|
||||
{
|
||||
private readonly VRioDevice _device = new();
|
||||
private readonly VRioSerialService _service;
|
||||
private readonly PanelCanvas _canvas = new();
|
||||
|
||||
private readonly ComboBox _portBox = new()
|
||||
{
|
||||
Location = new Point(80, 12),
|
||||
Width = 128,
|
||||
DropDownStyle = ComboBoxStyle.DropDownList,
|
||||
};
|
||||
private readonly Button _rescan = new() { Text = "Rescan", Location = new Point(214, 11), Width = 52 };
|
||||
private readonly Button _openClose = new() { Text = "Open", Location = new Point(272, 11), Width = 46 };
|
||||
private readonly Label _linkStatus = new()
|
||||
{
|
||||
Text = "Port closed.",
|
||||
Location = new Point(12, 42),
|
||||
AutoSize = true,
|
||||
ForeColor = Color.Gray,
|
||||
};
|
||||
|
||||
private readonly NumericUpDown _verMajor = new() { Location = new Point(80, 24), Width = 44, Minimum = 0, Maximum = 127, Value = 4 };
|
||||
private readonly NumericUpDown _verMinor = new() { Location = new Point(140, 24), Width = 44, Minimum = 0, Maximum = 127, Value = 2 };
|
||||
private readonly CheckBox _spring = new() { Text = "Stick springs back to center", Location = new Point(10, 56), AutoSize = true, Checked = true };
|
||||
private readonly Button _centerAxes = new() { Text = "Center all axes", Location = new Point(10, 82), Width = 140, Height = 26 };
|
||||
private readonly Button _lampsOff = new() { Text = "All lamps off", Location = new Point(156, 82), Width = 140, Height = 26 };
|
||||
private readonly Button _testEnter = new() { Text = "Enter test mode", Location = new Point(10, 114), Width = 140, Height = 26 };
|
||||
private readonly Button _testExit = new() { Text = "Exit test mode", Location = new Point(156, 114), Width = 140, Height = 26 };
|
||||
private readonly CheckBox _wedgeBug = new()
|
||||
{
|
||||
Text = "Emulate the v4.2 reply-wedge bug",
|
||||
Location = new Point(10, 148),
|
||||
AutoSize = true,
|
||||
};
|
||||
private readonly Button _wedgeNow = new() { Text = "Wedge analog now", Location = new Point(10, 172), Width = 140, Height = 26 };
|
||||
|
||||
private readonly Label _counters = new()
|
||||
{
|
||||
Location = new Point(12, 290),
|
||||
AutoSize = true,
|
||||
Font = new Font("Consolas", 8f),
|
||||
};
|
||||
|
||||
private readonly Label _help = new()
|
||||
{
|
||||
Location = new Point(12, 348),
|
||||
MaximumSize = new Size(306, 0),
|
||||
AutoSize = true,
|
||||
ForeColor = Color.Gray,
|
||||
Text = "Left-click a cell: momentary press. Right-click: latch it down. " +
|
||||
"Drag the X/Y box and the Z / L / R gauges to move the axes.",
|
||||
};
|
||||
|
||||
private readonly TextBox _logBox = new()
|
||||
{
|
||||
Location = new Point(12, 428),
|
||||
Multiline = true,
|
||||
ReadOnly = true,
|
||||
ScrollBars = ScrollBars.Vertical,
|
||||
BackColor = Color.FromArgb(24, 24, 24),
|
||||
ForeColor = Color.Gainsboro,
|
||||
Font = new Font("Consolas", 8f),
|
||||
Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left,
|
||||
WordWrap = false,
|
||||
};
|
||||
private readonly Button _clearLog = new()
|
||||
{
|
||||
Text = "Clear log",
|
||||
Width = 80,
|
||||
Anchor = AnchorStyles.Bottom | AnchorStyles.Left,
|
||||
};
|
||||
|
||||
private readonly System.Windows.Forms.Timer _uiTimer = new() { Interval = 500 };
|
||||
private readonly Stopwatch _clock = Stopwatch.StartNew();
|
||||
|
||||
public MainForm()
|
||||
{
|
||||
Text = "vRIO — Virtual RIO cockpit device";
|
||||
ClientSize = new Size(1150, 800);
|
||||
MinimumSize = new Size(1000, 620);
|
||||
StartPosition = FormStartPosition.CenterScreen;
|
||||
|
||||
_service = new VRioSerialService(_device);
|
||||
|
||||
// Panel canvas, scrolled if the window is smaller than the grid.
|
||||
var scroller = new Panel { Dock = DockStyle.Fill, AutoScroll = true, BackColor = Color.FromArgb(28, 28, 28) };
|
||||
scroller.Controls.Add(_canvas);
|
||||
|
||||
Controls.Add(scroller);
|
||||
Controls.Add(BuildControlStrip());
|
||||
|
||||
// Canvas ↔ device wiring.
|
||||
_canvas.LampProvider = _device.GetLamp;
|
||||
_canvas.AxisProvider = _device.GetAxis;
|
||||
_canvas.AddressPressed += _device.PressAddress;
|
||||
_canvas.AddressReleased += _device.ReleaseAddress;
|
||||
_canvas.AxisMoved += (axis, value) => _device.SetAxis(axis, value);
|
||||
|
||||
// Device / service events arrive on worker threads; marshal to the UI.
|
||||
_device.LampChanged += (_, _) => RunOnUi(_canvas.Invalidate);
|
||||
_device.AxesChanged += () => RunOnUi(_canvas.Invalidate);
|
||||
_device.Logged += line => RunOnUi(() => AppendLog(line));
|
||||
_service.Logged += line => RunOnUi(() => AppendLog(line));
|
||||
_service.ConnectionChanged += open => RunOnUi(() => OnConnectionChanged(open));
|
||||
_service.HostHandshake += high => RunOnUi(() =>
|
||||
AppendLog(high ? "Host raised DTR (board-reset handshake)" : "Host dropped DTR"));
|
||||
|
||||
_rescan.Click += (_, _) => RefreshPorts();
|
||||
_openClose.Click += (_, _) => ToggleOpen();
|
||||
_verMajor.ValueChanged += (_, _) => _device.VersionMajor = (byte)_verMajor.Value;
|
||||
_verMinor.ValueChanged += (_, _) => _device.VersionMinor = (byte)_verMinor.Value;
|
||||
_spring.CheckedChanged += (_, _) => _canvas.StickSpringsBack = _spring.Checked;
|
||||
_centerAxes.Click += (_, _) =>
|
||||
{
|
||||
foreach (RioAxis axis in (RioAxis[])Enum.GetValues(typeof(RioAxis)))
|
||||
_device.SetAxis(axis, 0);
|
||||
};
|
||||
_lampsOff.Click += (_, _) =>
|
||||
{
|
||||
_device.ClearLamps();
|
||||
_canvas.Invalidate();
|
||||
};
|
||||
_testEnter.Click += (_, _) => _device.SendTestMode(1);
|
||||
_testExit.Click += (_, _) => _device.SendTestMode(0);
|
||||
_wedgeBug.CheckedChanged += (_, _) => _device.EmulateReplyWedge = _wedgeBug.Checked;
|
||||
_wedgeNow.Click += (_, _) =>
|
||||
{
|
||||
_device.WedgeAnalogNow();
|
||||
UpdateStatus();
|
||||
};
|
||||
_clearLog.Click += (_, _) => _logBox.Clear();
|
||||
|
||||
_uiTimer.Tick += (_, _) => UpdateStatus();
|
||||
_uiTimer.Start();
|
||||
|
||||
FormClosed += (_, _) =>
|
||||
{
|
||||
_uiTimer.Dispose();
|
||||
_service.Dispose();
|
||||
};
|
||||
|
||||
RefreshPorts();
|
||||
UpdateStatus();
|
||||
AppendLog("vRIO ready. Open a COM port, then point RIOJoy at the other end of the pair.");
|
||||
}
|
||||
|
||||
private Panel BuildControlStrip()
|
||||
{
|
||||
var panel = new Panel
|
||||
{
|
||||
Dock = DockStyle.Right,
|
||||
Width = 330,
|
||||
Padding = new Padding(8),
|
||||
BorderStyle = BorderStyle.FixedSingle,
|
||||
};
|
||||
|
||||
panel.Controls.Add(new Label { Text = "COM port:", Location = new Point(12, 15), AutoSize = true });
|
||||
panel.Controls.Add(_portBox);
|
||||
panel.Controls.Add(_rescan);
|
||||
panel.Controls.Add(_openClose);
|
||||
panel.Controls.Add(_linkStatus);
|
||||
|
||||
var device = new GroupBox { Text = "Device", Location = new Point(12, 68), Size = new Size(306, 210) };
|
||||
device.Controls.Add(new Label { Text = "Firmware:", Location = new Point(10, 27), AutoSize = true });
|
||||
device.Controls.Add(_verMajor);
|
||||
device.Controls.Add(new Label { Text = ".", Location = new Point(127, 27), AutoSize = true });
|
||||
device.Controls.Add(_verMinor);
|
||||
device.Controls.AddRange(new Control[] { _spring, _centerAxes, _lampsOff, _testEnter, _testExit, _wedgeBug, _wedgeNow });
|
||||
panel.Controls.Add(device);
|
||||
|
||||
panel.Controls.Add(_counters);
|
||||
panel.Controls.Add(_help);
|
||||
panel.Controls.Add(new Label { Text = "Wire log:", Location = new Point(12, 410), AutoSize = true });
|
||||
|
||||
_logBox.Size = new Size(306, ClientSize.Height - _logBox.Top - 44);
|
||||
panel.Controls.Add(_logBox);
|
||||
|
||||
_clearLog.Location = new Point(12, ClientSize.Height - 36);
|
||||
panel.Controls.Add(_clearLog);
|
||||
|
||||
return panel;
|
||||
}
|
||||
|
||||
// ---- Port handling -----------------------------------------------------
|
||||
|
||||
private void RefreshPorts()
|
||||
{
|
||||
string? current = _portBox.SelectedItem?.ToString();
|
||||
_portBox.Items.Clear();
|
||||
foreach (string name in SerialPort.GetPortNames().Distinct().OrderBy(n => n, StringComparer.OrdinalIgnoreCase))
|
||||
_portBox.Items.Add(name);
|
||||
|
||||
if (_portBox.Items.Count == 0)
|
||||
return;
|
||||
int idx = current is null ? -1 : _portBox.Items.IndexOf(current);
|
||||
_portBox.SelectedIndex = idx >= 0 ? idx : 0;
|
||||
}
|
||||
|
||||
private void ToggleOpen()
|
||||
{
|
||||
if (_service.IsOpen)
|
||||
{
|
||||
_service.Close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_portBox.SelectedItem?.ToString() is not { } port)
|
||||
{
|
||||
MessageBox.Show(this, "No COM port selected. On a single PC, install a com0com virtual " +
|
||||
"null-modem pair and open one end here.", "vRIO", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_service.Open(port);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show(this, $"Could not open {port}:\n{ex.Message}", "vRIO",
|
||||
MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnConnectionChanged(bool open)
|
||||
{
|
||||
_openClose.Text = open ? "Close" : "Open";
|
||||
_portBox.Enabled = _rescan.Enabled = !open;
|
||||
_linkStatus.Text = open ? $"Serving {_service.PortName} @ 9600 8N1." : "Port closed.";
|
||||
_linkStatus.ForeColor = open ? Color.ForestGreen : Color.Gray;
|
||||
UpdateStatus();
|
||||
}
|
||||
|
||||
// ---- Status / log ------------------------------------------------------
|
||||
|
||||
private void UpdateStatus()
|
||||
{
|
||||
long polls = _device.AnalogRequests;
|
||||
long dropped = _device.AnalogDropped;
|
||||
long bad = _device.BadChecksums;
|
||||
bool wedged = _device.AnalogWedged;
|
||||
_counters.Text = $"Analog polls served: {polls}\n" +
|
||||
$"Analog polls dropped: {dropped}\n" +
|
||||
$"Bad checksums: {bad}";
|
||||
|
||||
_canvas.StatusText = _service.IsOpen
|
||||
? $"vRIO {_device.VersionMajor}.{_device.VersionMinor} on {_service.PortName}\n" +
|
||||
(wedged
|
||||
? "** ANALOG WEDGED (v4.2 bug) — awaiting host reset **"
|
||||
: $"{polls} analog polls" + (polls > 0 ? " (host is alive)" : " (no host traffic yet)"))
|
||||
: "PORT CLOSED\nOpen a COM port to go live.";
|
||||
}
|
||||
|
||||
private void AppendLog(string line)
|
||||
{
|
||||
// Bound the log so an all-day session doesn't grow without limit.
|
||||
if (_logBox.TextLength > 120_000)
|
||||
_logBox.Text = _logBox.Text.Substring(_logBox.TextLength - 60_000);
|
||||
|
||||
_logBox.AppendText($"[{_clock.Elapsed.TotalSeconds,8:F2}s] {line}{Environment.NewLine}");
|
||||
}
|
||||
|
||||
private void RunOnUi(Action action)
|
||||
{
|
||||
if (IsDisposed)
|
||||
return;
|
||||
if (InvokeRequired)
|
||||
{
|
||||
try { BeginInvoke(action); }
|
||||
catch (ObjectDisposedException) { }
|
||||
catch (InvalidOperationException) { } // handle not created / closing
|
||||
return;
|
||||
}
|
||||
action();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user