using System.Diagnostics; using System.IO.Ports; using VRio.Core.Device; namespace VRio.App; /// /// 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. /// 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"; // Fit the window to its content: the cockpit canvas plus the 330px // control strip, with just enough height for the strip's log area. ClientSize = new Size(_canvas.Width + 332, Math.Max(_canvas.Height, 640)); 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(() => PrependLog(line)); _service.Logged += line => RunOnUi(() => PrependLog(line)); _service.ConnectionChanged += open => RunOnUi(() => OnConnectionChanged(open)); _service.HostHandshake += high => RunOnUi(() => PrependLog(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(); PrependLog("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, // Height must be set before the anchored children are added: // Anchor offsets are captured against the parent's current size, // and the 100px default would pin the log box's bottom far below // the panel, making it grow past the window instead of scrolling. Height = ClientSize.Height, 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 PrependLog(string line) { // Newest entry on top; older lines flow downward and eventually fall // off the bounded end, so the visible top is always current. string text = $"[{_clock.Elapsed.TotalSeconds,8:F2}s] {line}{Environment.NewLine}" + _logBox.Text; // Bound the log so an all-day session doesn't grow without limit; // cut at a line boundary so the tail isn't half an entry. if (text.Length > 120_000) { int cut = text.LastIndexOf(Environment.NewLine, 60_000, StringComparison.Ordinal); text = text.Substring(0, cut < 0 ? 60_000 : cut + Environment.NewLine.Length); } _logBox.Text = text; _logBox.SelectionStart = 0; _logBox.ScrollToCaret(); } private void RunOnUi(Action action) { if (IsDisposed) return; if (InvokeRequired) { try { BeginInvoke(action); } catch (ObjectDisposedException) { } catch (InvalidOperationException) { } // handle not created / closing return; } action(); } }