Each device row now serves one endpoint at a time, picked from a list of pipe:vrio / pipe:vplasma (first, default) plus the machine's COM ports — nothing opens automatically at startup. The pipe services gained an IsListening property and are started/stopped by the row's Open/Close button instead of running for the whole app lifetime, which also retires the dual-transport warning. Status line shows the active endpoint; OFFLINE when none. Standalone vPLASMA is unchanged (hardwired COM12 + permanent pipe). Also fixes the xUnit1031 warnings in the plasma pipe tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
755 lines
30 KiB
C#
755 lines
30 KiB
C#
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;
|
|
|
|
/// <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 — 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
|
|
/// <see cref="RioPipeEndpoint"/>/<see cref="PlasmaPipeEndpoint"/> (or the
|
|
/// COM2 passthrough at the plasma pair) and the glass lights up.
|
|
/// </summary>
|
|
internal sealed class MainForm : Form
|
|
{
|
|
/// <summary>
|
|
/// The RIO's pipe endpoint as listed in the picker. Matches the
|
|
/// DOSBox-X conf syntax: <c>serial1=namedpipe pipe:vrio</c>.
|
|
/// </summary>
|
|
private static readonly string RioPipeEndpoint = $"pipe:{VRioPipeService.DefaultPipeName}";
|
|
|
|
/// <summary>
|
|
/// The plasma's pipe endpoint as listed in the picker
|
|
/// (<c>serial2=namedpipe pipe:vplasma</c>).
|
|
/// </summary>
|
|
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,
|
|
};
|
|
|
|
/// <summary>A keyboard choice in the lamp-mirror picker (null id = all).</summary>
|
|
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<string> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private void RunPlasmaSelfTest()
|
|
{
|
|
byte[] bytes = PlasmaSelfTest.BuildPage(_selfTestPage);
|
|
_plasmaDevice.OnReceived(bytes, bytes.Length);
|
|
_selfTestPage = (_selfTestPage + 1) % PlasmaSelfTest.PageCount;
|
|
}
|
|
|
|
// ---- Keyboard / gamepad input -------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Keys route to the panel unless the user is in a control that needs
|
|
/// them (port list, log box scrolling).
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>Apply the capture-picker selection: swap the keyboard input source.</summary>
|
|
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");
|
|
}
|
|
|
|
/// <summary>Refresh the capture picker, keeping the pick if the device survived.</summary>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>Refresh the lamp-mirror keyboard picker, keeping the pick if it survived.</summary>
|
|
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();
|
|
}
|
|
}
|