using System.Diagnostics; using System.IO.Ports; using VPlasma.App; using VPlasma.Core.Device; using VRio.Core.Device; using VRio.Core.Input; 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 — connection rows, device settings, and a live wire log. The /// panel's encoder strip also hosts the built-in plasma glass (the vPLASMA /// display emulator) on its own connection row. Each row's picker offers /// the device's named-pipe endpoint (the DOSBox-X fork's namedpipe backend /// — the com0com-free path) alongside the machine's COM ports; nothing /// opens automatically — pick an endpoint and Open. Point RIOJoy at the /// other end of a null-modem pair and every click here arrives exactly as /// if the physical cockpit sent it; point the game at /// / (or the /// COM2 passthrough at the plasma pair) and the glass lights up. /// internal sealed class MainForm : Form { /// /// The RIO's pipe endpoint as listed in the picker. Matches the /// DOSBox-X conf syntax: serial1=namedpipe pipe:vrio. /// private static readonly string RioPipeEndpoint = $"pipe:{VRioPipeService.DefaultPipeName}"; /// /// The plasma's pipe endpoint as listed in the picker /// (serial2=namedpipe pipe:vplasma). /// private static readonly string PlasmaPipeEndpoint = $"pipe:{VPlasmaPipeService.DefaultPipeName}"; private readonly VRioDevice _device = new(); private readonly VRioSerialService _service; private readonly VRioPipeService _pipeService; private readonly VPlasmaDevice _plasmaDevice = new(); private readonly VPlasmaSerialService _plasmaService; private readonly VPlasmaPipeService _plasmaPipeService; private readonly PanelCanvas _canvas = new(); private readonly PlasmaCanvas _plasmaCanvas = new(PanelCanvas.PlasmaDotPitch) { Location = PanelCanvas.PlasmaSlot, }; private int _selfTestPage; private readonly InputRouter _router; private readonly XInputGamepad _gamepad = new(); private readonly KeyboardLampMirror _lampMirror; private readonly RawKeyboardSource _rawKeyboard = new(); private string? _activeInputId; // interface path of the keyboard being captured; null = WinForms focus path private readonly string _bindingsPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "vRIO", "bindings.txt"); // Port rows: one line per built-in device — a name label whose colour is // the port status (green = open, gray = closed), the port picker, and an // Open/Close button. The one Rescan button refreshes both pickers. private readonly Label _rioLabel = new() { Text = "vRIO", Location = new Point(12, 15), AutoSize = true, ForeColor = Color.Gray, }; 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 _plasmaLabel = new() { Text = "vPLASMA", Location = new Point(12, 45), AutoSize = true, ForeColor = Color.Gray, }; private readonly ComboBox _plasmaPortBox = new() { Location = new Point(80, 42), Width = 128, DropDownStyle = ComboBoxStyle.DropDownList, }; private readonly Button _plasmaOpenClose = new() { Text = "Open", Location = new Point(272, 41), Width = 46 }; private readonly CheckBox _spring = new() { Text = "Stick springs back to center", Location = new Point(10, 24), AutoSize = true, Checked = true }; private readonly Button _centerAxes = new() { Text = "Center all axes", Location = new Point(10, 52), Width = 140, Height = 26 }; private readonly Button _lampsOff = new() { Text = "All lamps off", Location = new Point(156, 52), Width = 140, Height = 26 }; private readonly CheckBox _kbInput = new() { Text = "Keyboard", Location = new Point(10, 22), AutoSize = true, Checked = true }; private readonly CheckBox _padInput = new() { Text = "Gamepad", Location = new Point(100, 22), AutoSize = true, Checked = true }; private readonly CheckBox _invertY = new() { Text = "Invert Y", Location = new Point(190, 22), AutoSize = true }; private readonly Label _padStatus = new() { Text = "No controller detected.", Location = new Point(10, 46), AutoSize = true, ForeColor = Color.Gray, }; private readonly Button _reloadBindings = new() { Text = "Reload bindings", Location = new Point(10, 68), Width = 140, Height = 26 }; private readonly Button _editBindings = new() { Text = "Edit bindings…", Location = new Point(156, 68), Width = 140, Height = 26 }; private readonly CheckBox _kbLights = new() { Text = "Mirror lamps on RGB keyboard (Dynamic Lighting)", Location = new Point(10, 100), AutoSize = true, }; private readonly ComboBox _kbLightsTarget = new() { Location = new Point(10, 122), Width = 286, DropDownStyle = ComboBoxStyle.DropDownList, Enabled = false, // populated while the mirror is on }; private readonly Label _kbInputLabel = new() { Text = "Capture keyboard (no window focus needed):", Location = new Point(10, 144), AutoSize = true, }; private readonly ComboBox _kbInputTarget = new() { Location = new Point(10, 162), Width = 286, DropDownStyle = ComboBoxStyle.DropDownList, }; /// A keyboard choice in the lamp-mirror picker (null id = all). private sealed record KbChoice(string? Id, string Name) { public override string ToString() => Name; } private readonly Label _counters = new() { Location = new Point(12, 372), AutoSize = true, Font = new Font("Consolas", 8f), }; private readonly Label _help = new() { Location = new Point(12, 428), 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. " + "Double-click the plasma glass to cycle its self-test pages; right-click it to reset.", }; private readonly TextBox _logBox = new() { Location = new Point(12, 498), Multiline = true, ReadOnly = true, ScrollBars = ScrollBars.Both, // long wire lines don't wrap — scroll to read 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 System.Windows.Forms.Timer _inputTimer = new() { Interval = 16 }; private readonly Stopwatch _clock = Stopwatch.StartNew(); private double _lastInputTick; public MainForm() { // ProductVersion carries the git stamp (see StampGitVersion in the csproj). Text = $"vRIO v{Application.ProductVersion} — Virtual RIO cockpit device"; // Title-bar/taskbar icon from the exe's embedded ApplicationIcon (vwe.ico). Icon = Icon.ExtractAssociatedIcon(Application.ExecutablePath); // 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; KeyPreview = true; // form-level key routing for the input bindings _service = new VRioSerialService(_device); _plasmaService = new VPlasmaSerialService(_plasmaDevice); // The named-pipe endpoints for DOSBox-X's namedpipe serial backend, // offered in the connection pickers alongside the COM ports. _pipeService = new VRioPipeService(_device); _plasmaPipeService = new VPlasmaPipeService(_plasmaDevice); _router = new InputRouter(_device); _lampMirror = new KeyboardLampMirror(_device); // Panel canvas, scrolled if the window is smaller than the grid. The // plasma glass rides along as a child in the encoder strip's left slot. var scroller = new Panel { Dock = DockStyle.Fill, AutoScroll = true, BackColor = Color.FromArgb(28, 28, 28) }; _canvas.Controls.Add(_plasmaCanvas); scroller.Controls.Add(_canvas); Controls.Add(scroller); Controls.Add(BuildControlStrip()); // Canvas ↔ device wiring. _canvas.LampProvider = _device.GetLamp; _canvas.AxisProvider = _device.GetAxis; _canvas.WireAxisProvider = _device.GetWireAxis; _canvas.AddressPressed += _device.PressAddress; _canvas.AddressReleased += _device.ReleaseAddress; _canvas.AxisMoved += (axis, value) => _device.SetAxis(axis, value); // Router-driven presses light up like clicks (router runs on the UI thread). _router.AddressHeldChanged += _canvas.SetExternalHeld; // Device / service events arrive on worker threads; marshal to the UI. _device.LampChanged += (_, _) => RunOnUi(_canvas.Invalidate); _device.AxesChanged += () => RunOnUi(_canvas.Invalidate); // A host reset re-zeroes the axes behind the router's back. _device.ResetReceived += _ => RunOnUi(_router.ResetAxisState); _device.Logged += line => RunOnUi(() => PrependLog(line)); _service.Logged += line => RunOnUi(() => PrependLog(line)); _lampMirror.Logged += line => RunOnUi(() => PrependLog(line)); _service.ConnectionChanged += _ => RunOnUi(UpdateRioEndpointUi); _service.HostHandshake += high => RunOnUi(() => PrependLog(high ? "Host raised DTR (board-reset handshake)" : "Host dropped DTR")); _pipeService.Logged += line => RunOnUi(() => PrependLog($"pipe: {line}")); _pipeService.HostHandshake += high => RunOnUi(() => PrependLog(high ? "Pipe host raised DTR (board-reset handshake)" : "Pipe host dropped DTR")); _plasmaPipeService.Logged += line => RunOnUi(() => PrependLog($"vPLASMA pipe: {line}")); // Built-in plasma glass: a pure listener on its own port, same gestures // as the standalone vPLASMA — double-click cycles the self-test pages, // right-click resets the display to its power-on state. _plasmaDevice.Updated += () => RunOnUi(() => _plasmaCanvas.UpdateFrom(_plasmaDevice)); _plasmaService.Logged += line => RunOnUi(() => PrependLog($"vPLASMA: {line}")); _plasmaService.ConnectionChanged += _ => RunOnUi(UpdatePlasmaEndpointUi); _plasmaCanvas.DoubleClick += (_, _) => RunPlasmaSelfTest(); _plasmaCanvas.MouseClick += (_, e) => { if (e.Button == MouseButtons.Right) _plasmaDevice.Reset(); }; _rescan.Click += (_, _) => RefreshPorts(); _openClose.Click += (_, _) => ToggleOpen(); _plasmaOpenClose.Click += (_, _) => TogglePlasmaOpen(); _spring.CheckedChanged += (_, _) => _canvas.StickSpringsBack = _spring.Checked; _centerAxes.Click += (_, _) => { foreach (RioAxis axis in (RioAxis[])Enum.GetValues(typeof(RioAxis))) _device.SetAxis(axis, 0); _router.ResetAxisState(); }; _lampsOff.Click += (_, _) => { _device.ClearLamps(); _canvas.Invalidate(); }; _clearLog.Click += (_, _) => _logBox.Clear(); _kbInput.CheckedChanged += (_, _) => { if (!_kbInput.Checked) _router.ReleaseAllKeys(); }; _invertY.CheckedChanged += (_, _) => { _device.InvertJoystickY = _invertY.Checked; _canvas.Invalidate(); // the wire readout flips even though the axes didn't move }; _device.InvertJoystickY = _invertY.Checked; // the field initializer raises no event _kbLights.CheckedChanged += (_, _) => { _lampMirror.Enabled = _kbLights.Checked; _kbLightsTarget.Enabled = _kbLights.Checked; }; _kbLightsTarget.SelectedIndexChanged += (_, _) => _lampMirror.SetTarget((_kbLightsTarget.SelectedItem as KbChoice)?.Id); _lampMirror.KeyboardsChanged += list => RunOnUi(() => RebuildKeyboardPicker(list)); _kbLightsTarget.Items.Add(new KbChoice(null, "All keyboards")); _kbLightsTarget.SelectedIndex = 0; // Raw-input capture: a chosen keyboard drives the panel even while the // sim has focus. Down-edges only fire when keyboard input is enabled; // up-edges always land so a release is never stranded by a mid-hold flip. _rawKeyboard.KeyDown += name => RunOnUi(() => { if (_kbInput.Checked) _router.KeyDown(name); }); _rawKeyboard.KeyUp += name => RunOnUi(() => _router.KeyUp(name)); _rawKeyboard.Logged += line => RunOnUi(() => PrependLog(line)); _rawKeyboard.KeyboardsChanged += list => RunOnUi(() => RebuildInputKeyboardPicker(list)); _kbInputTarget.SelectedIndexChanged += (_, _) => ApplyInputSource(); _kbInputTarget.DropDown += (_, _) => _rawKeyboard.RefreshKeyboards(); _kbInputTarget.Items.Add(new KbChoice(null, "All keyboards (focus only)")); _kbInputTarget.SelectedIndex = 0; _reloadBindings.Click += (_, _) => LoadBindings(); _editBindings.Click += (_, _) => OpenBindingsFile(); _uiTimer.Tick += (_, _) => UpdateStatus(); _uiTimer.Start(); _inputTimer.Tick += (_, _) => InputTick(); _inputTimer.Start(); FormClosed += (_, _) => { _uiTimer.Dispose(); _inputTimer.Dispose(); _lampMirror.Dispose(); _rawKeyboard.Dispose(); _service.Dispose(); _plasmaService.Dispose(); _pipeService.Dispose(); _plasmaPipeService.Dispose(); }; RefreshPorts(); UpdateStatus(); PrependLog($"vRIO ready. Pick an endpoint per device — {RioPipeEndpoint} / {PlasmaPipeEndpoint} " + "for DOSBox-X, or a COM port for RIOJoy — and Open."); LoadBindings(); _plasmaCanvas.UpdateFrom(_plasmaDevice); // paint the power-on frame } 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(_rioLabel); panel.Controls.Add(_portBox); panel.Controls.Add(_rescan); panel.Controls.Add(_openClose); panel.Controls.Add(_plasmaLabel); panel.Controls.Add(_plasmaPortBox); panel.Controls.Add(_plasmaOpenClose); var device = new GroupBox { Text = "Device", Location = new Point(12, 68), Size = new Size(306, 88) }; device.Controls.AddRange(new Control[] { _spring, _centerAxes, _lampsOff }); panel.Controls.Add(device); var input = new GroupBox { Text = "Input", Location = new Point(12, 162), Size = new Size(306, 202) }; input.Controls.AddRange(new Control[] { _kbInput, _padInput, _invertY, _padStatus, _reloadBindings, _editBindings, _kbLights, _kbLightsTarget, _kbInputLabel, _kbInputTarget }); panel.Controls.Add(input); panel.Controls.Add(_counters); panel.Controls.Add(_help); panel.Controls.Add(new Label { Text = "Wire log:", Location = new Point(12, 480), 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; } // ---- Endpoint handling --------------------------------------------------- // Each device row serves one endpoint at a time — the RIO is a // single-host device, so its pipe server and COM port never run together. private bool RioOpen => _service.IsOpen || _pipeService.IsListening; private bool PlasmaOpen => _plasmaService.IsOpen || _plasmaPipeService.IsListening; private void RefreshPorts() { string[] names = SerialPort.GetPortNames().Distinct() .OrderBy(n => n, StringComparer.OrdinalIgnoreCase).ToArray(); // An open row's picker is disabled and shows the served endpoint; // leave it alone so the display can't drift from reality. if (!RioOpen) RefreshPicker(_portBox, RioPipeEndpoint, names); if (!PlasmaOpen) RefreshPicker(_plasmaPortBox, PlasmaPipeEndpoint, names); } private static void RefreshPicker(ComboBox box, string pipeEndpoint, string[] portNames) { string? current = box.SelectedItem?.ToString(); box.Items.Clear(); box.Items.Add(pipeEndpoint); // always present — a pipe server needs no hardware foreach (string name in portNames) box.Items.Add(name); int idx = current is null ? -1 : box.Items.IndexOf(current); box.SelectedIndex = idx >= 0 ? idx : 0; } private void ToggleOpen() { if (RioOpen) { _service.Close(); // idempotent — _pipeService.Stop(); // whichever was serving closes UpdateRioEndpointUi(); } else { OpenFromPicker(_portBox, OpenRioEndpoint); } } private void TogglePlasmaOpen() { if (PlasmaOpen) { _plasmaService.Close(); _plasmaPipeService.Stop(); UpdatePlasmaEndpointUi(); } else { OpenFromPicker(_plasmaPortBox, OpenPlasmaEndpoint); } } private void OpenRioEndpoint(string endpoint) { if (endpoint == RioPipeEndpoint) _pipeService.Start(); else _service.Open(endpoint); UpdateRioEndpointUi(); } private void OpenPlasmaEndpoint(string endpoint) { if (endpoint == PlasmaPipeEndpoint) _plasmaPipeService.Start(); else _plasmaService.Open(endpoint); UpdatePlasmaEndpointUi(); } private void OpenFromPicker(ComboBox box, Action open) { if (box.SelectedItem?.ToString() is not { } endpoint) { MessageBox.Show(this, "No endpoint selected. Pick the device's named pipe (for " + "DOSBox-X's namedpipe backend) or a COM port (for RIOJoy through a com0com " + "null-modem pair).", "vRIO", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } try { open(endpoint); } catch (Exception ex) { MessageBox.Show(this, $"Could not open {endpoint}:\n{ex.Message}", "vRIO", MessageBoxButtons.OK, MessageBoxIcon.Warning); } } private void UpdateRioEndpointUi() { bool open = RioOpen; _openClose.Text = open ? "Close" : "Open"; _portBox.Enabled = !open; _rioLabel.ForeColor = open ? Color.ForestGreen : Color.Gray; UpdateStatus(); } private void UpdatePlasmaEndpointUi() { bool open = PlasmaOpen; _plasmaOpenClose.Text = open ? "Close" : "Open"; _plasmaPortBox.Enabled = !open; _plasmaLabel.ForeColor = open ? Color.ForestGreen : Color.Gray; } /// /// Feed the next canned self-test page through the plasma's wire parser — /// the same end-to-end exercise the standalone vPLASMA runs on double-click. /// private void RunPlasmaSelfTest() { byte[] bytes = PlasmaSelfTest.BuildPage(_selfTestPage); _plasmaDevice.OnReceived(bytes, bytes.Length); _selfTestPage = (_selfTestPage + 1) % PlasmaSelfTest.PageCount; } // ---- Keyboard / gamepad input ------------------------------------------- /// /// Keys route to the panel unless the user is in a control that needs /// them (port list, log box scrolling). /// private bool KeyboardRoutingActive => _kbInput.Checked && !_portBox.ContainsFocus && !_logBox.ContainsFocus; protected override void OnHandleCreated(EventArgs e) { base.OnHandleCreated(e); _rawKeyboard.Attach(Handle); _rawKeyboard.RefreshKeyboards(); // seed the capture picker } protected override void WndProc(ref Message m) { // Raw Input arrives outside the normal key-message path; feed it to the // capture source, then let DefWindowProc run its WM_INPUT cleanup. const int WM_INPUT = 0x00FF, WM_INPUT_DEVICE_CHANGE = 0x00FE; switch (m.Msg) { case WM_INPUT: _rawKeyboard.ProcessInput(m.LParam); break; case WM_INPUT_DEVICE_CHANGE: _rawKeyboard.HandleDeviceChange(); break; } base.WndProc(ref m); } protected override bool ProcessCmdKey(ref Message msg, Keys keyData) { // Intercept WM_KEYDOWN before dialog-key processing, or arrows/space // would move focus and click buttons instead of reaching the panel. const int WM_KEYDOWN = 0x0100, WM_SYSKEYDOWN = 0x0104; if ((msg.Msg is WM_KEYDOWN or WM_SYSKEYDOWN) && (keyData & (Keys.Control | Keys.Alt)) == 0 && KeyboardRoutingActive) { string name = (keyData & Keys.KeyCode).ToString(); if (_router.HasKeyBinding(name)) { // While a specific keyboard is captured, that source owns // routing; still swallow the key so it can't click a button. if (!_rawKeyboard.IsCapturing) _router.KeyDown(name); return true; } } return base.ProcessCmdKey(ref msg, keyData); } protected override void OnKeyUp(KeyEventArgs e) { // Unconditional in focus mode: the router ignores keys it never saw go // down, and a release must land even if the checkbox flipped mid-hold. // In capture mode the raw source owns edges, so don't double-release. if (!_rawKeyboard.IsCapturing) _router.KeyUp(e.KeyCode.ToString()); base.OnKeyUp(e); } protected override void OnDeactivate(EventArgs e) { // Focus mode loses key-up events once unfocused, so release held keys. // Capture mode keeps receiving them in the background — leave holds be. if (!_rawKeyboard.IsCapturing) _router.ReleaseAllKeys(); base.OnDeactivate(e); } /// Apply the capture-picker selection: swap the keyboard input source. private void ApplyInputSource() { string? id = (_kbInputTarget.SelectedItem as KbChoice)?.Id; if (id == _activeInputId) return; _activeInputId = id; _router.ReleaseAllKeys(); // don't strand holds across a source swap _rawKeyboard.SetTarget(id); PrependLog(id is null ? "Keyboard input: all keyboards, only while vRIO has focus" : $"Keyboard input: capturing \"{(_kbInputTarget.SelectedItem as KbChoice)?.Name}\" in the background"); } /// Refresh the capture picker, keeping the pick if the device survived. private void RebuildInputKeyboardPicker(IReadOnlyList<(string Id, string Name)> keyboards) { string? selected = (_kbInputTarget.SelectedItem as KbChoice)?.Id; _kbInputTarget.BeginUpdate(); _kbInputTarget.Items.Clear(); _kbInputTarget.Items.Add(new KbChoice(null, "All keyboards (focus only)")); int select = 0; foreach ((string id, string name) in keyboards) { int idx = _kbInputTarget.Items.Add(new KbChoice(id, name)); if (id == selected) select = idx; } // A vanished capture device falls back to "All keyboards"; the // selection-changed handler (ApplyInputSource) unregisters accordingly. _kbInputTarget.SelectedIndex = select; _kbInputTarget.EndUpdate(); } private void InputTick() { double now = _clock.Elapsed.TotalSeconds; double dt = Math.Min(0.25, now - _lastInputTick); // cap catch-up after a stall _lastInputTick = now; if (_padInput.Checked && _gamepad.TryRead(out var pad)) _router.SetPadState(pad); else _router.SetPadState(default); // releases whatever the pad held _router.Tick(dt); string status = _padInput.Checked ? _gamepad.Connected ? $"Controller #{_gamepad.UserIndex + 1} connected." : "No controller detected." : "Gamepad input off."; if (_padStatus.Text != status) { _padStatus.Text = status; _padStatus.ForeColor = _gamepad.Connected && _padInput.Checked ? Color.ForestGreen : Color.Gray; } } private void LoadBindings() { try { string text; if (File.Exists(_bindingsPath)) { text = File.ReadAllText(_bindingsPath); } else { // First run: materialize the commented default file so // "Edit bindings…" has something self-documenting to open. Directory.CreateDirectory(Path.GetDirectoryName(_bindingsPath)!); File.WriteAllText(_bindingsPath, BindingProfileFormat.DefaultText); text = BindingProfileFormat.DefaultText; } var profile = BindingProfileFormat.Parse(text, out var errors); _router.Profile = profile; _lampMirror.SetProfile(profile); foreach (string error in errors) PrependLog($"Bindings: {error}"); PrependLog($"Bindings loaded: {profile.Count} ({_bindingsPath})"); } catch (Exception ex) { PrependLog($"Bindings load failed: {ex.Message}"); } } private void OpenBindingsFile() { try { if (!File.Exists(_bindingsPath)) LoadBindings(); // writes the default file Process.Start(new ProcessStartInfo(_bindingsPath) { UseShellExecute = true }); } catch (Exception ex) { MessageBox.Show(this, $"Could not open {_bindingsPath}:\n{ex.Message}", "vRIO", MessageBoxButtons.OK, MessageBoxIcon.Warning); } } /// Refresh the lamp-mirror keyboard picker, keeping the pick if it survived. private void RebuildKeyboardPicker(IReadOnlyList<(string Id, string Name)> keyboards) { string? selected = (_kbLightsTarget.SelectedItem as KbChoice)?.Id; _kbLightsTarget.BeginUpdate(); _kbLightsTarget.Items.Clear(); _kbLightsTarget.Items.Add(new KbChoice(null, "All keyboards")); int select = 0; foreach ((string id, string name) in keyboards) { int idx = _kbLightsTarget.Items.Add(new KbChoice(id, name)); if (id == selected) select = idx; } // Falls back to "All keyboards" if the picked device vanished (the // selection-changed handler re-targets the mirror accordingly). _kbLightsTarget.SelectedIndex = select; _kbLightsTarget.EndUpdate(); } // ---- 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}"; string? endpoint = _service.IsOpen ? _service.PortName : _pipeService.IsListening ? RioPipeEndpoint : null; _canvas.StatusText = endpoint is not null ? $"vRIO {_device.VersionMajor}.{_device.VersionMinor} on {endpoint}\n" + (wedged ? "** ANALOG WEDGED (v4.2 bug) — awaiting host reset **" : $"{polls} analog polls" + (polls > 0 ? " (host is alive)" : " (no host traffic yet)")) : "OFFLINE\nOpen an endpoint (named pipe or 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(); } }