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:
+11
@@ -0,0 +1,11 @@
|
|||||||
|
# Build output
|
||||||
|
[Bb]in/
|
||||||
|
[Oo]bj/
|
||||||
|
|
||||||
|
# IDE state
|
||||||
|
.vs/
|
||||||
|
*.user
|
||||||
|
*.suo
|
||||||
|
|
||||||
|
# Test results
|
||||||
|
TestResults/
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# vRIO — virtual RIO cockpit device
|
||||||
|
|
||||||
|
A software replica of the cockpit **RIO** (Remote Input/Output) board: it opens
|
||||||
|
a COM port and speaks the **device side** of the RIO serial protocol, so any
|
||||||
|
host that expects the real hardware — most importantly
|
||||||
|
[RIOJoy](../riojoy/) — can talk to it without a cockpit attached.
|
||||||
|
|
||||||
|
The window is an interactive version of the cockpit control panel that
|
||||||
|
RIOJoy's profile editor draws (the same functional layout from the original
|
||||||
|
Win32 RIO design: five MFD clusters, four board columns, two keypads, encoder
|
||||||
|
gauges). But where the editor *edits bindings*, vRIO's cells are the physical
|
||||||
|
controls:
|
||||||
|
|
||||||
|
- **Left-click** a cell — momentary button press (`ButtonPressed`/`Released`
|
||||||
|
or `KeyPressed`/`Released` on the wire). **Right-click** — latch it down.
|
||||||
|
- **Drag** the X/Y box and the Z / L / R gauges — the five analog axes,
|
||||||
|
returned by the next `AnalogReply` (14-bit signed, 7-bit-pair packed).
|
||||||
|
- Cells shade to the **lamp state the host commands** (`LampRequest`:
|
||||||
|
off / dim / bright, with slow/med/fast flash), so RIOJoy's press-feedback
|
||||||
|
lights the on-screen panel just like the real buttons.
|
||||||
|
|
||||||
|
## Wire behavior
|
||||||
|
|
||||||
|
Protocol per `riojoy/docs/PROTOCOL.md` (9600 8N1, `[cmd][payload…][7-bit
|
||||||
|
checksum]`, ACK `0xFC` / NAK `0xFD` / RESTART `0xFE` / IDLE `0xFF`), with
|
||||||
|
device behavior grounded in the **real v4.2 firmware dump**
|
||||||
|
(`riojoy/rio-firmware/RIOv4_2-ANALYSIS.md`):
|
||||||
|
|
||||||
|
- ACKs every well-formed packet; NAKs bad-checksum packets.
|
||||||
|
- `CheckRequest` → one `BoardOk` CheckReply per board (the 11 boards from the
|
||||||
|
legacy firmware's table). `VersionRequest` → configurable version,
|
||||||
|
default **4.2**.
|
||||||
|
- `ResetRequest` re-zeroes the targeted axis (or all).
|
||||||
|
- A NAK re-sends the last event up to **4 times**, then gives up with a
|
||||||
|
RESTART byte — the real board's retry budget.
|
||||||
|
- Optional **v4.2 reply-wedge emulation**: after retry exhaustion (or the
|
||||||
|
"Wedge analog now" button), analog requests are silently dropped — still
|
||||||
|
ACK'd, RX path alive — until a host `ResetRequest`, reproducing the
|
||||||
|
latch-leak fault the firmware analysis documents. Use it to exercise
|
||||||
|
RIOJoy's 5-second no-analog recovery watchdog.
|
||||||
|
|
||||||
|
## Using it with RIOJoy on one PC
|
||||||
|
|
||||||
|
The two apps need a crossed serial link. Install a
|
||||||
|
[com0com](https://com0com.sourceforge.net/) virtual null-modem pair
|
||||||
|
(e.g. `COM5 ⇄ COM6`), then:
|
||||||
|
|
||||||
|
1. Run `VRio.App`, pick `COM5`, **Open**.
|
||||||
|
2. Point RIOJoy at `COM6`.
|
||||||
|
|
||||||
|
RIOJoy's DTR open-pulse shows up in the wire log (DSR handshake), its ~55 ms
|
||||||
|
analog polling drives the "analog polls served" counter, and every click on
|
||||||
|
the vRIO panel arrives at RIOJoy as real cockpit input. Two physical PCs with
|
||||||
|
a null-modem cable work the same way.
|
||||||
|
|
||||||
|
## Repository layout
|
||||||
|
|
||||||
|
| Path | Contents |
|
||||||
|
|------|----------|
|
||||||
|
| `src/VRio.Core` | Protocol framing/builder/parser, the `VRioDevice` state machine, serial pump, panel layout data (class library) |
|
||||||
|
| `src/VRio.App` | WinForms panel UI |
|
||||||
|
| `tests/VRio.Core.Tests` | xUnit tests for the protocol + device engine |
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
Same toolchain as RIOJoy: **.NET SDK** (8.0+) with the **.NET Framework 4.8**
|
||||||
|
targeting pack; apps target net48 so deployed builds run in-box on
|
||||||
|
Windows 10/11.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
dotnet build VRio.sln -c Release
|
||||||
|
dotnet test VRio.sln
|
||||||
|
```
|
||||||
|
|
||||||
|
Interop is additionally verified against RIOJoy's real `RioSerialLink`
|
||||||
|
(version/check/analog/lamp/button/keypad/reset round-trips over an in-memory
|
||||||
|
transport) — see the RIOJoy repo for the host side.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
|
||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
# Visual Studio Version 17
|
||||||
|
VisualStudioVersion = 17.0.31903.59
|
||||||
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{D0ECC9D9-7379-4759-89F7-56CD3214BD57}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VRio.Core", "src\VRio.Core\VRio.Core.csproj", "{80312F43-09BF-4F09-A0FA-A60FDF86274D}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VRio.App", "src\VRio.App\VRio.App.csproj", "{2D1A482C-D907-47EB-9830-B78132154E57}"
|
||||||
|
EndProject
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{C4993638-7EB6-47A9-897C-976DB9939601}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VRio.Core.Tests", "tests\VRio.Core.Tests\VRio.Core.Tests.csproj", "{986638BB-F289-4480-8575-F1699D201590}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
|
HideSolutionNode = FALSE
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{80312F43-09BF-4F09-A0FA-A60FDF86274D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{80312F43-09BF-4F09-A0FA-A60FDF86274D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{80312F43-09BF-4F09-A0FA-A60FDF86274D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{80312F43-09BF-4F09-A0FA-A60FDF86274D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{2D1A482C-D907-47EB-9830-B78132154E57}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{2D1A482C-D907-47EB-9830-B78132154E57}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{2D1A482C-D907-47EB-9830-B78132154E57}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{2D1A482C-D907-47EB-9830-B78132154E57}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{986638BB-F289-4480-8575-F1699D201590}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{986638BB-F289-4480-8575-F1699D201590}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{986638BB-F289-4480-8575-F1699D201590}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{986638BB-F289-4480-8575-F1699D201590}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(NestedProjects) = preSolution
|
||||||
|
{80312F43-09BF-4F09-A0FA-A60FDF86274D} = {D0ECC9D9-7379-4759-89F7-56CD3214BD57}
|
||||||
|
{2D1A482C-D907-47EB-9830-B78132154E57} = {D0ECC9D9-7379-4759-89F7-56CD3214BD57}
|
||||||
|
{986638BB-F289-4480-8575-F1699D201590} = {C4993638-7EB6-47A9-897C-976DB9939601}
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,470 @@
|
|||||||
|
using VRio.Core.Device;
|
||||||
|
using VRio.Core.Panel;
|
||||||
|
using VRio.Core.Protocol;
|
||||||
|
|
||||||
|
namespace VRio.App;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The interactive cockpit control panel. Visually it mirrors RIOJoy's profile
|
||||||
|
/// editor map (same functional groups, cell geometry, and lamp colours), but
|
||||||
|
/// here the cells are the <em>physical controls</em>: pressing one emits a
|
||||||
|
/// button/keypad packet on the wire, and the fill shade tracks the lamp state
|
||||||
|
/// the host last commanded (including flash modes). The encoder strip is live
|
||||||
|
/// too — drag the X/Y box and the Z/L/R gauges to move the analog axes.
|
||||||
|
///
|
||||||
|
/// <para>Left-click = momentary press (release on mouse-up). Right-click =
|
||||||
|
/// latch the button down / release it (handy for testing holds).</para>
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class PanelCanvas : Control
|
||||||
|
{
|
||||||
|
// Cell geometry — identical to RIOJoy's PanelView so the two panels align.
|
||||||
|
private const int CellW = 66;
|
||||||
|
private const int CellH = 34;
|
||||||
|
private const int TopStrip = 108;
|
||||||
|
|
||||||
|
private static readonly IReadOnlyList<PanelButton> AllButtons = CockpitLayout.Buttons();
|
||||||
|
|
||||||
|
// Encoder-strip geometry (same placement as RIOJoy's PanelView): Z gauge,
|
||||||
|
// the pedals' "U" (L / R arms + Rz bar), and the X/Y stick box.
|
||||||
|
private const int StripTop = 6;
|
||||||
|
private const int StripH = 68;
|
||||||
|
private const int Bar = 30;
|
||||||
|
private static readonly int BaseX = 6 * CellW - 100;
|
||||||
|
private static readonly Rectangle BoxZ = new(BaseX, StripTop, Bar, StripH);
|
||||||
|
private static readonly Rectangle BoxL = new(BaseX + 34, StripTop, Bar, StripH);
|
||||||
|
private static readonly Rectangle BoxR = new(BaseX + 34 + 82 - Bar, StripTop, Bar, StripH);
|
||||||
|
private static readonly Rectangle BoxRz = new(BaseX + 34, StripTop + StripH, 82, Bar);
|
||||||
|
private static readonly Rectangle BoxXY = new(BaseX + 120, StripTop, 80, StripH);
|
||||||
|
|
||||||
|
private readonly System.Windows.Forms.Timer _flashTimer = new() { Interval = 100 };
|
||||||
|
private bool _wasFlashing;
|
||||||
|
|
||||||
|
private readonly HashSet<int> _latched = new();
|
||||||
|
private int? _mouseDownAddress;
|
||||||
|
|
||||||
|
private RioAxis? _dragAxis; // Z / L / R gauge drag
|
||||||
|
private bool _dragStick; // X/Y box drag
|
||||||
|
|
||||||
|
private string _statusText = string.Empty;
|
||||||
|
|
||||||
|
public PanelCanvas()
|
||||||
|
{
|
||||||
|
DoubleBuffered = true;
|
||||||
|
BackColor = Color.FromArgb(28, 28, 28);
|
||||||
|
Size = GridSize();
|
||||||
|
|
||||||
|
_flashTimer.Tick += (_, _) =>
|
||||||
|
{
|
||||||
|
bool flashing = AnyLampFlashing();
|
||||||
|
if (flashing || _wasFlashing)
|
||||||
|
Invalidate();
|
||||||
|
_wasFlashing = flashing;
|
||||||
|
};
|
||||||
|
_flashTimer.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Current lamp-state byte for an address (from the device).</summary>
|
||||||
|
public Func<int, byte>? LampProvider { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Current raw value of an axis (from the device).</summary>
|
||||||
|
public Func<RioAxis, short>? AxisProvider { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The user pressed a panel control.</summary>
|
||||||
|
public event Action<int>? AddressPressed;
|
||||||
|
|
||||||
|
/// <summary>The user released a panel control.</summary>
|
||||||
|
public event Action<int>? AddressReleased;
|
||||||
|
|
||||||
|
/// <summary>The user dragged an axis to a new raw value.</summary>
|
||||||
|
public event Action<RioAxis, short>? AxisMoved;
|
||||||
|
|
||||||
|
/// <summary>When true (default), the X/Y stick snaps back to center on release.</summary>
|
||||||
|
public bool StickSpringsBack { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Green status text drawn in the strip area (the mockup's status panel).</summary>
|
||||||
|
public string StatusText
|
||||||
|
{
|
||||||
|
get => _statusText;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (_statusText == value) return;
|
||||||
|
_statusText = value;
|
||||||
|
Invalidate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
if (disposing) _flashTimer.Dispose();
|
||||||
|
base.Dispose(disposing);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Size GridSize()
|
||||||
|
{
|
||||||
|
int maxCol = 0, maxRow = 0;
|
||||||
|
foreach (PanelButton b in AllButtons)
|
||||||
|
{
|
||||||
|
if (b.Col > maxCol) maxCol = b.Col;
|
||||||
|
if (b.Row > maxRow) maxRow = b.Row;
|
||||||
|
}
|
||||||
|
return new Size((maxCol + 1) * CellW + 6, TopStrip + (maxRow + 2) * CellH);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Rectangle Cell(int col, int row) =>
|
||||||
|
new(col * CellW + 2, TopStrip + row * CellH + 2, CellW - 4, CellH - 4);
|
||||||
|
|
||||||
|
// ---- Painting ----------------------------------------------------------
|
||||||
|
|
||||||
|
protected override void OnPaint(PaintEventArgs e)
|
||||||
|
{
|
||||||
|
Graphics g = e.Graphics;
|
||||||
|
g.Clear(Color.FromArgb(28, 28, 28));
|
||||||
|
|
||||||
|
using var titleFont = new Font("Segoe UI", 8f, FontStyle.Bold);
|
||||||
|
using var btnFont = new Font("Segoe UI", 7.5f);
|
||||||
|
using var subFont = new Font("Segoe UI", 6.75f);
|
||||||
|
using var groupPen = new Pen(Color.FromArgb(80, 80, 80));
|
||||||
|
|
||||||
|
DrawEncoderStrip(g, titleFont, subFont);
|
||||||
|
|
||||||
|
// Group frames + titles.
|
||||||
|
foreach (PanelGroup grp in CockpitLayout.Groups)
|
||||||
|
{
|
||||||
|
var frame = new Rectangle(
|
||||||
|
grp.OriginCol * CellW + 1,
|
||||||
|
TopStrip + grp.OriginRow * CellH + 1,
|
||||||
|
grp.Cols * CellW,
|
||||||
|
(grp.Rows + 1) * CellH);
|
||||||
|
g.DrawRectangle(groupPen, frame);
|
||||||
|
TextRenderer.DrawText(g, grp.Title, titleFont,
|
||||||
|
new Rectangle(frame.X + 2, frame.Y, frame.Width - 2, CellH), Color.Gainsboro,
|
||||||
|
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TextFormatFlags oneLine = TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter |
|
||||||
|
TextFormatFlags.EndEllipsis | TextFormatFlags.NoPrefix;
|
||||||
|
int tick = Environment.TickCount;
|
||||||
|
|
||||||
|
foreach (PanelButton b in AllButtons)
|
||||||
|
{
|
||||||
|
Rectangle r = Cell(b.Col, b.Row);
|
||||||
|
bool held = IsHeld(b.Address);
|
||||||
|
bool yellow = b.Group.Title is "Secondary" or "Screen";
|
||||||
|
|
||||||
|
// Lamp shade: locally held renders bright (a real pressed cap floods
|
||||||
|
// bright); otherwise the host-commanded lamp state decides, with the
|
||||||
|
// flash bits blinking between the commanded brightness and off.
|
||||||
|
LampBrightness shade = LampBrightness.Off;
|
||||||
|
if (b.LampCapable)
|
||||||
|
{
|
||||||
|
byte state = LampProvider?.Invoke(b.Address) ?? 0;
|
||||||
|
shade = RioLampState.Brightness(state);
|
||||||
|
if (shade != LampBrightness.Off && !FlashPhaseOn(RioLampState.Flash(state), tick))
|
||||||
|
shade = LampBrightness.Off;
|
||||||
|
}
|
||||||
|
if (held)
|
||||||
|
shade = LampBrightness.Bright;
|
||||||
|
|
||||||
|
Color fill = !b.LampCapable
|
||||||
|
? (held ? Color.FromArgb(205, 228, 255) // keypad — bright blue when pressed
|
||||||
|
: Color.FromArgb(150, 182, 226)) // keypad — neutral blue
|
||||||
|
: yellow
|
||||||
|
? shade switch
|
||||||
|
{
|
||||||
|
LampBrightness.Bright => Color.FromArgb(245, 210, 60),
|
||||||
|
LampBrightness.Dim => Color.FromArgb(140, 118, 38),
|
||||||
|
_ => Color.FromArgb(70, 60, 24),
|
||||||
|
}
|
||||||
|
: shade switch
|
||||||
|
{
|
||||||
|
LampBrightness.Bright => Color.FromArgb(230, 70, 70),
|
||||||
|
LampBrightness.Dim => Color.FromArgb(120, 50, 50),
|
||||||
|
_ => Color.FromArgb(64, 40, 40),
|
||||||
|
};
|
||||||
|
using (var fb = new SolidBrush(fill)) g.FillRectangle(fb, r);
|
||||||
|
|
||||||
|
Color fg = (b.LampCapable && !yellow) ? Color.White : Color.Black;
|
||||||
|
string hex = $"{b.Address:X2}";
|
||||||
|
string? label = CockpitLayout.PhysicalLabel(b.Address);
|
||||||
|
|
||||||
|
if (label is not null && b.Group.Kind != PanelGroupKind.Keypad)
|
||||||
|
{
|
||||||
|
TextRenderer.DrawText(g, label, btnFont, new Rectangle(r.X, r.Y + 1, r.Width, r.Height / 2), fg,
|
||||||
|
TextFormatFlags.HorizontalCenter | TextFormatFlags.Top | TextFormatFlags.EndEllipsis | TextFormatFlags.NoPrefix);
|
||||||
|
TextRenderer.DrawText(g, hex, subFont, new Rectangle(r.X, r.Bottom - r.Height / 2 - 1, r.Width, r.Height / 2), fg,
|
||||||
|
TextFormatFlags.HorizontalCenter | TextFormatFlags.Bottom | TextFormatFlags.EndEllipsis | TextFormatFlags.NoPrefix);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
TextRenderer.DrawText(g, label ?? hex, btnFont, r, fg, oneLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_latched.Contains(b.Address))
|
||||||
|
{
|
||||||
|
using var latch = new Pen(Color.Gold, 2f);
|
||||||
|
g.DrawRectangle(latch, r.X, r.Y, r.Width - 1, r.Height - 1);
|
||||||
|
}
|
||||||
|
else if (held)
|
||||||
|
{
|
||||||
|
using var hi = new Pen(Color.White, 2f);
|
||||||
|
g.DrawRectangle(hi, r.X, r.Y, r.Width - 1, r.Height - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsHeld(int address) => _mouseDownAddress == address || _latched.Contains(address);
|
||||||
|
|
||||||
|
private static bool FlashPhaseOn(LampFlash flash, int tick)
|
||||||
|
{
|
||||||
|
int halfPeriod = flash switch
|
||||||
|
{
|
||||||
|
LampFlash.FlashSlow => 500,
|
||||||
|
LampFlash.FlashMed => 250,
|
||||||
|
LampFlash.FlashFast => 125,
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
return halfPeriod == 0 || tick / halfPeriod % 2 == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool AnyLampFlashing()
|
||||||
|
{
|
||||||
|
if (LampProvider is not { } lamps)
|
||||||
|
return false;
|
||||||
|
for (int a = 0; a < RioAddressSpace.LampCount; a++)
|
||||||
|
{
|
||||||
|
byte state = lamps(a);
|
||||||
|
if (RioLampState.Flash(state) != LampFlash.Solid && RioLampState.Brightness(state) != LampBrightness.Off)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawEncoderStrip(Graphics g, Font labelFont, Font valueFont)
|
||||||
|
{
|
||||||
|
using var pen = new Pen(Color.FromArgb(120, 160, 120));
|
||||||
|
using var bg = new SolidBrush(Color.FromArgb(20, 40, 20));
|
||||||
|
using var fillBrush = new SolidBrush(Color.FromArgb(90, 190, 90));
|
||||||
|
|
||||||
|
short Axis(RioAxis a) => AxisProvider?.Invoke(a) ?? (short)0;
|
||||||
|
|
||||||
|
// Vertical gauges: Z (throttle) and the pedal arms L / R.
|
||||||
|
DrawVGauge(g, pen, bg, fillBrush, labelFont, BoxZ, "Z", Axis(RioAxis.Throttle));
|
||||||
|
DrawVGauge(g, pen, bg, fillBrush, labelFont, BoxL, "L", Axis(RioAxis.LeftPedal));
|
||||||
|
DrawVGauge(g, pen, bg, fillBrush, labelFont, BoxR, "R", Axis(RioAxis.RightPedal));
|
||||||
|
|
||||||
|
// Rz bar: the PC-side rudder mix ((R − L) / 2) — display only.
|
||||||
|
g.FillRectangle(bg, BoxRz);
|
||||||
|
g.DrawRectangle(pen, BoxRz);
|
||||||
|
int mix = (Axis(RioAxis.RightPedal) - Axis(RioAxis.LeftPedal)) / 2;
|
||||||
|
int mx = BoxRz.X + (int)((mix - (float)AnalogCodec.Min) / (AnalogCodec.Max - AnalogCodec.Min) * BoxRz.Width);
|
||||||
|
g.FillRectangle(fillBrush, Math.Max(BoxRz.X + 1, Math.Min(mx - 2, BoxRz.Right - 4)), BoxRz.Y + 1, 3, BoxRz.Height - 2);
|
||||||
|
TextRenderer.DrawText(g, "Rz", labelFont, BoxRz, Color.Gainsboro,
|
||||||
|
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
|
||||||
|
|
||||||
|
// X/Y stick box with a crosshair at the current position.
|
||||||
|
g.FillRectangle(bg, BoxXY);
|
||||||
|
g.DrawRectangle(pen, BoxXY);
|
||||||
|
using (var mid = new Pen(Color.FromArgb(50, 90, 50)))
|
||||||
|
{
|
||||||
|
g.DrawLine(mid, BoxXY.X, BoxXY.Y + BoxXY.Height / 2, BoxXY.Right, BoxXY.Y + BoxXY.Height / 2);
|
||||||
|
g.DrawLine(mid, BoxXY.X + BoxXY.Width / 2, BoxXY.Y, BoxXY.X + BoxXY.Width / 2, BoxXY.Bottom);
|
||||||
|
}
|
||||||
|
PointF stick = StickToPoint(Axis(RioAxis.JoystickX), Axis(RioAxis.JoystickY));
|
||||||
|
g.FillEllipse(fillBrush, stick.X - 4, stick.Y - 4, 8, 8);
|
||||||
|
TextRenderer.DrawText(g, "X / Y", labelFont,
|
||||||
|
new Rectangle(BoxXY.X, BoxXY.Bottom - 16, BoxXY.Width, 14), Color.Gainsboro,
|
||||||
|
TextFormatFlags.HorizontalCenter | TextFormatFlags.Bottom);
|
||||||
|
|
||||||
|
// The mockup's green status/instruction area, right of the gauges, with a
|
||||||
|
// live axis readout on its last line (painted per frame, so drags track).
|
||||||
|
var statusRect = new Rectangle(BoxXY.Right + 16, StripTop, Width - BoxXY.Right - 24, TopStrip - StripTop - 6);
|
||||||
|
if (statusRect.Width > 60)
|
||||||
|
{
|
||||||
|
using var statusFont = new Font("Consolas", 8f);
|
||||||
|
var green = Color.FromArgb(120, 220, 120);
|
||||||
|
if (_statusText.Length > 0)
|
||||||
|
TextRenderer.DrawText(g, _statusText, statusFont, statusRect, green,
|
||||||
|
TextFormatFlags.Left | TextFormatFlags.Top | TextFormatFlags.WordBreak);
|
||||||
|
|
||||||
|
string readout =
|
||||||
|
$"Z {Axis(RioAxis.Throttle),6} L {Axis(RioAxis.LeftPedal),6} R {Axis(RioAxis.RightPedal),6} " +
|
||||||
|
$"X {Axis(RioAxis.JoystickX),6} Y {Axis(RioAxis.JoystickY),6}";
|
||||||
|
TextRenderer.DrawText(g, readout, statusFont, statusRect, green,
|
||||||
|
TextFormatFlags.Left | TextFormatFlags.Bottom | TextFormatFlags.SingleLine);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DrawVGauge(Graphics g, Pen pen, Brush bg, Brush fill, Font labelFont,
|
||||||
|
Rectangle box, string label, short value)
|
||||||
|
{
|
||||||
|
g.FillRectangle(bg, box);
|
||||||
|
// Fill upward from the bottom, proportional to the normalized value.
|
||||||
|
float norm = (value - (float)AnalogCodec.Min) / (AnalogCodec.Max - AnalogCodec.Min);
|
||||||
|
int h = (int)(norm * (box.Height - 2));
|
||||||
|
if (h > 0)
|
||||||
|
g.FillRectangle(fill, box.X + 1, box.Bottom - 1 - h, box.Width - 2, h);
|
||||||
|
g.DrawRectangle(pen, box);
|
||||||
|
TextRenderer.DrawText(g, label, labelFont, box, Color.Gainsboro,
|
||||||
|
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PointF StickToPoint(short x, short y)
|
||||||
|
{
|
||||||
|
float nx = (x - (float)AnalogCodec.Min) / (AnalogCodec.Max - AnalogCodec.Min);
|
||||||
|
float ny = (y - (float)AnalogCodec.Min) / (AnalogCodec.Max - AnalogCodec.Min);
|
||||||
|
return new PointF(
|
||||||
|
BoxXY.X + 1 + nx * (BoxXY.Width - 2),
|
||||||
|
BoxXY.Bottom - 1 - ny * (BoxXY.Height - 2)); // up = positive Y
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Interaction -------------------------------------------------------
|
||||||
|
|
||||||
|
protected override void OnMouseDown(MouseEventArgs e)
|
||||||
|
{
|
||||||
|
base.OnMouseDown(e);
|
||||||
|
Focus();
|
||||||
|
|
||||||
|
// Axis gauges first (they live above the button grid).
|
||||||
|
if (e.Button == MouseButtons.Left)
|
||||||
|
{
|
||||||
|
if (BoxXY.Contains(e.Location))
|
||||||
|
{
|
||||||
|
_dragStick = true;
|
||||||
|
Capture = true;
|
||||||
|
UpdateStick(e.Location);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
RioAxis? gauge = HitGauge(e.Location);
|
||||||
|
if (gauge is { } axis)
|
||||||
|
{
|
||||||
|
_dragAxis = axis;
|
||||||
|
Capture = true;
|
||||||
|
UpdateGauge(axis, e.Location);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PanelButton? hit = HitButton(e.Location);
|
||||||
|
if (hit is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (e.Button == MouseButtons.Right)
|
||||||
|
{
|
||||||
|
// Latch toggle: press-and-hold without keeping the mouse down.
|
||||||
|
if (_latched.Remove(hit.Address))
|
||||||
|
AddressReleased?.Invoke(hit.Address);
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_latched.Add(hit.Address);
|
||||||
|
AddressPressed?.Invoke(hit.Address);
|
||||||
|
}
|
||||||
|
Invalidate();
|
||||||
|
}
|
||||||
|
else if (e.Button == MouseButtons.Left)
|
||||||
|
{
|
||||||
|
if (_latched.Remove(hit.Address))
|
||||||
|
{
|
||||||
|
// Left-clicking a latched button releases it.
|
||||||
|
AddressReleased?.Invoke(hit.Address);
|
||||||
|
Invalidate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_mouseDownAddress = hit.Address;
|
||||||
|
AddressPressed?.Invoke(hit.Address);
|
||||||
|
Invalidate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnMouseMove(MouseEventArgs e)
|
||||||
|
{
|
||||||
|
base.OnMouseMove(e);
|
||||||
|
if (_dragStick)
|
||||||
|
UpdateStick(e.Location);
|
||||||
|
else if (_dragAxis is { } axis)
|
||||||
|
UpdateGauge(axis, e.Location);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnMouseUp(MouseEventArgs e)
|
||||||
|
{
|
||||||
|
base.OnMouseUp(e);
|
||||||
|
|
||||||
|
if (_dragStick)
|
||||||
|
{
|
||||||
|
_dragStick = false;
|
||||||
|
Capture = false;
|
||||||
|
if (StickSpringsBack)
|
||||||
|
{
|
||||||
|
AxisMoved?.Invoke(RioAxis.JoystickX, 0);
|
||||||
|
AxisMoved?.Invoke(RioAxis.JoystickY, 0);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_dragAxis is not null)
|
||||||
|
{
|
||||||
|
_dragAxis = null;
|
||||||
|
Capture = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ReleaseMomentary();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnMouseCaptureChanged(EventArgs e)
|
||||||
|
{
|
||||||
|
base.OnMouseCaptureChanged(e);
|
||||||
|
_dragStick = false;
|
||||||
|
_dragAxis = null;
|
||||||
|
ReleaseMomentary();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReleaseMomentary()
|
||||||
|
{
|
||||||
|
if (_mouseDownAddress is not { } addr)
|
||||||
|
return;
|
||||||
|
_mouseDownAddress = null;
|
||||||
|
AddressReleased?.Invoke(addr);
|
||||||
|
Invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PanelButton? HitButton(Point p)
|
||||||
|
{
|
||||||
|
foreach (PanelButton b in AllButtons)
|
||||||
|
if (Cell(b.Col, b.Row).Contains(p))
|
||||||
|
return b;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RioAxis? HitGauge(Point p)
|
||||||
|
{
|
||||||
|
if (BoxZ.Contains(p)) return RioAxis.Throttle;
|
||||||
|
if (BoxL.Contains(p)) return RioAxis.LeftPedal;
|
||||||
|
if (BoxR.Contains(p)) return RioAxis.RightPedal;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateStick(Point p)
|
||||||
|
{
|
||||||
|
short x = FromNorm((p.X - BoxXY.X) / (float)BoxXY.Width);
|
||||||
|
short y = FromNorm((BoxXY.Bottom - p.Y) / (float)BoxXY.Height);
|
||||||
|
AxisMoved?.Invoke(RioAxis.JoystickX, x);
|
||||||
|
AxisMoved?.Invoke(RioAxis.JoystickY, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateGauge(RioAxis axis, Point p)
|
||||||
|
{
|
||||||
|
Rectangle box = axis switch
|
||||||
|
{
|
||||||
|
RioAxis.Throttle => BoxZ,
|
||||||
|
RioAxis.LeftPedal => BoxL,
|
||||||
|
_ => BoxR,
|
||||||
|
};
|
||||||
|
short v = FromNorm((box.Bottom - p.Y) / (float)box.Height);
|
||||||
|
AxisMoved?.Invoke(axis, v);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static short FromNorm(float norm)
|
||||||
|
{
|
||||||
|
norm = Math.Max(0f, Math.Min(1f, norm));
|
||||||
|
return (short)(AnalogCodec.Min + norm * (AnalogCodec.Max - AnalogCodec.Min));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace VRio.App;
|
||||||
|
|
||||||
|
internal static class Program
|
||||||
|
{
|
||||||
|
[STAThread]
|
||||||
|
private static void Main()
|
||||||
|
{
|
||||||
|
Application.EnableVisualStyles();
|
||||||
|
Application.SetCompatibleTextRenderingDefault(false);
|
||||||
|
Application.Run(new MainForm());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<TargetFramework>net48</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<UseWindowsForms>true</UseWindowsForms>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
|
<AssemblyTitle>vRIO — Virtual RIO device</AssemblyTitle>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\VRio.Core\VRio.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="PolySharp" Version="1.14.1">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||||
|
<assemblyIdentity version="1.0.0.0" name="VRio.App" type="win32" />
|
||||||
|
|
||||||
|
<!-- Run as the invoking user: vRIO only opens a COM port and draws a window. -->
|
||||||
|
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<security>
|
||||||
|
<requestedPrivileges>
|
||||||
|
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
|
||||||
|
</requestedPrivileges>
|
||||||
|
</security>
|
||||||
|
</trustInfo>
|
||||||
|
|
||||||
|
<!-- Declare Windows 10/11 compatibility for correct DPI / API behavior. -->
|
||||||
|
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||||
|
<application>
|
||||||
|
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" /> <!-- Windows 10/11 -->
|
||||||
|
</application>
|
||||||
|
</compatibility>
|
||||||
|
</assembly>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
using VRio.Core.Protocol;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace VRio.Core.Tests;
|
||||||
|
|
||||||
|
public class ProtocolTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Checksum_is_low7_of_sum_of_low7()
|
||||||
|
{
|
||||||
|
// 0x88 & 0x7F = 0x08, + 0x05 = 0x0D.
|
||||||
|
Assert.Equal(0x0D, RioChecksum.Compute(new byte[] { 0x88, 0x05 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0)]
|
||||||
|
[InlineData(1)]
|
||||||
|
[InlineData(-1)]
|
||||||
|
[InlineData(8191)]
|
||||||
|
[InlineData(-8192)]
|
||||||
|
[InlineData(1234)]
|
||||||
|
[InlineData(-4321)]
|
||||||
|
public void AnalogCodec_round_trips_and_stays_7bit(short value)
|
||||||
|
{
|
||||||
|
AnalogCodec.Split(value, out byte low, out byte high);
|
||||||
|
|
||||||
|
Assert.Equal(0, low & 0x80); // payload bytes must keep the high bit clear
|
||||||
|
Assert.Equal(0, high & 0x80);
|
||||||
|
Assert.Equal(value, AnalogCodec.Combine(low, high));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Builder_output_parses_back_with_valid_checksum()
|
||||||
|
{
|
||||||
|
byte[] wire = PacketBuilder.AnalogReply(100, -200, 300, -8192, 8191);
|
||||||
|
|
||||||
|
var parser = new PacketParser();
|
||||||
|
RioRxEvent? got = null;
|
||||||
|
foreach (byte b in wire)
|
||||||
|
if (parser.Feed(b, out RioRxEvent ev))
|
||||||
|
got = ev;
|
||||||
|
|
||||||
|
Assert.NotNull(got);
|
||||||
|
Assert.Equal(RioRxKind.Packet, got!.Value.Kind);
|
||||||
|
Assert.True(got.Value.ChecksumValid);
|
||||||
|
Assert.Equal(RioCommand.AnalogReply, got.Value.Packet.Command);
|
||||||
|
|
||||||
|
byte[] p = got.Value.Packet.Payload;
|
||||||
|
Assert.Equal(100, AnalogCodec.Combine(p[0], p[1]));
|
||||||
|
Assert.Equal(-200, AnalogCodec.Combine(p[2], p[3]));
|
||||||
|
Assert.Equal(300, AnalogCodec.Combine(p[4], p[5]));
|
||||||
|
Assert.Equal(-8192, AnalogCodec.Combine(p[6], p[7]));
|
||||||
|
Assert.Equal(8191, AnalogCodec.Combine(p[8], p[9]));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parser_reports_control_bytes_outside_framing()
|
||||||
|
{
|
||||||
|
var parser = new PacketParser();
|
||||||
|
Assert.True(parser.Feed((byte)RioControl.Ack, out RioRxEvent ev));
|
||||||
|
Assert.Equal(RioRxKind.ControlByte, ev.Kind);
|
||||||
|
Assert.Equal((byte)RioControl.Ack, ev.Byte);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parser_resyncs_on_high_bit_byte_mid_packet()
|
||||||
|
{
|
||||||
|
var parser = new PacketParser();
|
||||||
|
|
||||||
|
// Start a LampRequest (2-byte payload), then interrupt with a new command byte.
|
||||||
|
Assert.False(parser.Feed(0x84, out _));
|
||||||
|
Assert.True(parser.Feed(0xFF, out RioRxEvent err)); // IDLE mid-packet = framing error
|
||||||
|
Assert.Equal(RioRxKind.FramingError, err.Kind);
|
||||||
|
Assert.False(parser.InPacket);
|
||||||
|
|
||||||
|
// A clean packet right after must still parse.
|
||||||
|
byte[] wire = PacketBuilder.VersionReply(1, 2);
|
||||||
|
RioRxEvent? got = null;
|
||||||
|
foreach (byte b in wire)
|
||||||
|
if (parser.Feed(b, out RioRxEvent ev))
|
||||||
|
got = ev;
|
||||||
|
Assert.Equal(RioRxKind.Packet, got!.Value.Kind);
|
||||||
|
Assert.True(got.Value.ChecksumValid);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0x00, LampBrightness.Off, LampFlash.Solid)]
|
||||||
|
[InlineData(0x14, LampBrightness.Dim, LampFlash.Solid)]
|
||||||
|
[InlineData(0x3C, LampBrightness.Bright, LampFlash.Solid)]
|
||||||
|
[InlineData(0x3D, LampBrightness.Bright, LampFlash.FlashSlow)]
|
||||||
|
[InlineData(0x16, LampBrightness.Dim, LampFlash.FlashMed)]
|
||||||
|
[InlineData(0x04, LampBrightness.Dim, LampFlash.Solid)] // field 1 only
|
||||||
|
[InlineData(0x30, LampBrightness.Bright, LampFlash.Solid)] // field 2 only
|
||||||
|
public void LampState_decodes_brightness_and_flash(byte state, LampBrightness brightness, LampFlash flash)
|
||||||
|
{
|
||||||
|
Assert.Equal(brightness, RioLampState.Brightness(state));
|
||||||
|
Assert.Equal(flash, RioLampState.Flash(state));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net48</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.2" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||||
|
<PackageReference Include="PolySharp" Version="1.14.1">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\VRio.Core\VRio.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
using VRio.Core.Device;
|
||||||
|
using VRio.Core.Protocol;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace VRio.Core.Tests;
|
||||||
|
|
||||||
|
public class VRioDeviceTests
|
||||||
|
{
|
||||||
|
/// <summary>Captures the device's transmissions and re-frames them for asserts.</summary>
|
||||||
|
private sealed class Wire
|
||||||
|
{
|
||||||
|
private readonly PacketParser _parser = new();
|
||||||
|
public readonly List<RioPacket> Packets = new();
|
||||||
|
public readonly List<byte> Controls = new();
|
||||||
|
|
||||||
|
public Wire(VRioDevice device) => device.Transmit += OnBytes;
|
||||||
|
|
||||||
|
private void OnBytes(byte[] data)
|
||||||
|
{
|
||||||
|
foreach (byte b in data)
|
||||||
|
{
|
||||||
|
if (!_parser.Feed(b, out RioRxEvent ev))
|
||||||
|
continue;
|
||||||
|
if (ev.Kind == RioRxKind.Packet)
|
||||||
|
{
|
||||||
|
Assert.True(ev.ChecksumValid, $"device sent a bad checksum on {ev.Packet}");
|
||||||
|
Packets.Add(ev.Packet);
|
||||||
|
}
|
||||||
|
else if (ev.Kind == RioRxKind.ControlByte)
|
||||||
|
{
|
||||||
|
Controls.Add(ev.Byte);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Clear()
|
||||||
|
{
|
||||||
|
Packets.Clear();
|
||||||
|
Controls.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Send(VRioDevice device, byte[] bytes) => device.OnReceived(bytes, bytes.Length);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnalogRequest_returns_current_axes_and_acks()
|
||||||
|
{
|
||||||
|
var device = new VRioDevice();
|
||||||
|
var wire = new Wire(device);
|
||||||
|
device.SetAxis(RioAxis.Throttle, 1000);
|
||||||
|
device.SetAxis(RioAxis.JoystickX, -5000);
|
||||||
|
|
||||||
|
Send(device, PacketBuilder.Build(RioCommand.AnalogRequest));
|
||||||
|
|
||||||
|
Assert.Equal((byte)RioControl.Ack, Assert.Single(wire.Controls));
|
||||||
|
RioPacket reply = Assert.Single(wire.Packets);
|
||||||
|
Assert.Equal(RioCommand.AnalogReply, reply.Command);
|
||||||
|
|
||||||
|
byte[] p = reply.Payload;
|
||||||
|
Assert.Equal(1000, AnalogCodec.Combine(p[0], p[1])); // throttle
|
||||||
|
Assert.Equal(0, AnalogCodec.Combine(p[2], p[3])); // left pedal
|
||||||
|
Assert.Equal(0, AnalogCodec.Combine(p[4], p[5])); // right pedal
|
||||||
|
Assert.Equal(0, AnalogCodec.Combine(p[6], p[7])); // joystick Y
|
||||||
|
Assert.Equal(-5000, AnalogCodec.Combine(p[8], p[9])); // joystick X
|
||||||
|
Assert.Equal(1, device.AnalogRequests);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void VersionRequest_reports_configured_firmware()
|
||||||
|
{
|
||||||
|
var device = new VRioDevice { VersionMajor = 2, VersionMinor = 7 };
|
||||||
|
var wire = new Wire(device);
|
||||||
|
|
||||||
|
Send(device, PacketBuilder.Build(RioCommand.VersionRequest));
|
||||||
|
|
||||||
|
RioPacket reply = Assert.Single(wire.Packets);
|
||||||
|
Assert.Equal(RioCommand.VersionReply, reply.Command);
|
||||||
|
Assert.Equal(new byte[] { 2, 7 }, reply.Payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CheckRequest_reports_every_board_ok()
|
||||||
|
{
|
||||||
|
var device = new VRioDevice();
|
||||||
|
var wire = new Wire(device);
|
||||||
|
|
||||||
|
Send(device, PacketBuilder.Build(RioCommand.CheckRequest));
|
||||||
|
|
||||||
|
Assert.Equal(RioAddressSpace.Boards.Count, wire.Packets.Count);
|
||||||
|
Assert.All(wire.Packets, p =>
|
||||||
|
{
|
||||||
|
Assert.Equal(RioCommand.CheckReply, p.Command);
|
||||||
|
Assert.Equal((byte)RioStatusType.BoardOk, p.Payload[0]);
|
||||||
|
});
|
||||||
|
Assert.Equal(
|
||||||
|
RioAddressSpace.Boards.Select(b => b.Number),
|
||||||
|
wire.Packets.Select(p => p.Payload[1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LampRequest_updates_lamp_state_and_raises_event()
|
||||||
|
{
|
||||||
|
var device = new VRioDevice();
|
||||||
|
_ = new Wire(device);
|
||||||
|
(int Address, byte State)? change = null;
|
||||||
|
device.LampChanged += (a, s) => change = (a, s);
|
||||||
|
|
||||||
|
Send(device, PacketBuilder.Build(RioCommand.LampRequest, new byte[] { 0x05, RioLampState.SolidBright }));
|
||||||
|
|
||||||
|
Assert.Equal((0x05, RioLampState.SolidBright), change);
|
||||||
|
Assert.Equal(RioLampState.SolidBright, device.GetLamp(0x05));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResetRequest_zeroes_the_targeted_axis()
|
||||||
|
{
|
||||||
|
var device = new VRioDevice();
|
||||||
|
_ = new Wire(device);
|
||||||
|
device.SetAxis(RioAxis.Throttle, 4000);
|
||||||
|
device.SetAxis(RioAxis.JoystickY, -3000);
|
||||||
|
|
||||||
|
Send(device, PacketBuilder.Build(RioCommand.ResetRequest, new[] { (byte)RioResetTarget.Throttle }));
|
||||||
|
Assert.Equal(0, device.GetAxis(RioAxis.Throttle));
|
||||||
|
Assert.Equal(-3000, device.GetAxis(RioAxis.JoystickY)); // untouched
|
||||||
|
|
||||||
|
Send(device, PacketBuilder.Build(RioCommand.ResetRequest, new[] { (byte)RioResetTarget.All }));
|
||||||
|
Assert.Equal(0, device.GetAxis(RioAxis.JoystickY));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Bad_checksum_gets_nak_and_no_reply()
|
||||||
|
{
|
||||||
|
var device = new VRioDevice();
|
||||||
|
var wire = new Wire(device);
|
||||||
|
|
||||||
|
byte[] bad = PacketBuilder.Build(RioCommand.AnalogRequest);
|
||||||
|
bad[bad.Length - 1] ^= 0x01; // corrupt the checksum
|
||||||
|
Send(device, bad);
|
||||||
|
|
||||||
|
Assert.Equal((byte)RioControl.Nak, Assert.Single(wire.Controls));
|
||||||
|
Assert.Empty(wire.Packets);
|
||||||
|
Assert.Equal(1, device.BadChecksums);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Button_press_and_release_use_button_packets()
|
||||||
|
{
|
||||||
|
var device = new VRioDevice();
|
||||||
|
var wire = new Wire(device);
|
||||||
|
|
||||||
|
device.PressAddress(0x3D); // Panic
|
||||||
|
device.ReleaseAddress(0x3D);
|
||||||
|
|
||||||
|
Assert.Equal(2, wire.Packets.Count);
|
||||||
|
Assert.Equal(RioCommand.ButtonPressed, wire.Packets[0].Command);
|
||||||
|
Assert.Equal(new byte[] { 0x3D }, wire.Packets[0].Payload);
|
||||||
|
Assert.Equal(RioCommand.ButtonReleased, wire.Packets[1].Command);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0x51, 0, 0x1)] // internal keypad "1"
|
||||||
|
[InlineData(0x5F, 0, 0xF)] // internal keypad "F"
|
||||||
|
[InlineData(0x60, 1, 0x0)] // external keypad "0"
|
||||||
|
[InlineData(0x6C, 1, 0xC)] // external keypad "C"
|
||||||
|
public void Keypad_press_uses_key_packets_with_pad_and_index(int address, byte pad, byte index)
|
||||||
|
{
|
||||||
|
var device = new VRioDevice();
|
||||||
|
var wire = new Wire(device);
|
||||||
|
|
||||||
|
device.PressAddress(address);
|
||||||
|
|
||||||
|
RioPacket packet = Assert.Single(wire.Packets);
|
||||||
|
Assert.Equal(RioCommand.KeyPressed, packet.Command);
|
||||||
|
Assert.Equal(new[] { pad, index }, packet.Payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Nak_resends_four_times_then_gives_up_with_restart()
|
||||||
|
{
|
||||||
|
var device = new VRioDevice();
|
||||||
|
var wire = new Wire(device);
|
||||||
|
|
||||||
|
device.PressAddress(0x00);
|
||||||
|
Assert.Single(wire.Packets);
|
||||||
|
|
||||||
|
// The v4.2 firmware retry budget: 4 re-sends, then RESTART and give up.
|
||||||
|
for (int i = 0; i < 4; i++)
|
||||||
|
Send(device, new[] { (byte)RioControl.Nak });
|
||||||
|
Assert.Equal(5, wire.Packets.Count);
|
||||||
|
Assert.All(wire.Packets, p => Assert.Equal(RioCommand.ButtonPressed, p.Command));
|
||||||
|
Assert.Empty(wire.Controls);
|
||||||
|
|
||||||
|
Send(device, new[] { (byte)RioControl.Nak }); // budget exhausted
|
||||||
|
Assert.Equal(5, wire.Packets.Count);
|
||||||
|
Assert.Equal((byte)RioControl.Restart, Assert.Single(wire.Controls));
|
||||||
|
|
||||||
|
// An ACK clears the pending event; a following NAK re-sends nothing.
|
||||||
|
wire.Clear();
|
||||||
|
device.PressAddress(0x01);
|
||||||
|
Send(device, new[] { (byte)RioControl.Ack });
|
||||||
|
Send(device, new[] { (byte)RioControl.Nak });
|
||||||
|
Assert.Single(wire.Packets);
|
||||||
|
Assert.Empty(wire.Controls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Wedge_emulation_mutes_analog_until_host_reset()
|
||||||
|
{
|
||||||
|
var device = new VRioDevice { EmulateReplyWedge = true };
|
||||||
|
var wire = new Wire(device);
|
||||||
|
|
||||||
|
// Exhaust the retry budget to trip the latch leak.
|
||||||
|
device.PressAddress(0x00);
|
||||||
|
for (int i = 0; i < 5; i++)
|
||||||
|
Send(device, new[] { (byte)RioControl.Nak });
|
||||||
|
Assert.True(device.AnalogWedged);
|
||||||
|
wire.Clear();
|
||||||
|
|
||||||
|
// Wedged: the request is ACK'd (RX path alive) but no reply comes back.
|
||||||
|
Send(device, PacketBuilder.Build(RioCommand.AnalogRequest));
|
||||||
|
Assert.Equal((byte)RioControl.Ack, Assert.Single(wire.Controls));
|
||||||
|
Assert.Empty(wire.Packets);
|
||||||
|
Assert.Equal(1, device.AnalogDropped);
|
||||||
|
|
||||||
|
// The host's recovery reset clears the latch; analog replies resume.
|
||||||
|
Send(device, PacketBuilder.Build(RioCommand.ResetRequest, new[] { (byte)RioResetTarget.All }));
|
||||||
|
Assert.False(device.AnalogWedged);
|
||||||
|
wire.Clear();
|
||||||
|
Send(device, PacketBuilder.Build(RioCommand.AnalogRequest));
|
||||||
|
Assert.Equal(RioCommand.AnalogReply, Assert.Single(wire.Packets).Command);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WedgeAnalogNow_wedges_without_the_emulation_flag()
|
||||||
|
{
|
||||||
|
var device = new VRioDevice();
|
||||||
|
var wire = new Wire(device);
|
||||||
|
|
||||||
|
device.WedgeAnalogNow();
|
||||||
|
Assert.True(device.AnalogWedged);
|
||||||
|
|
||||||
|
Send(device, PacketBuilder.Build(RioCommand.AnalogRequest));
|
||||||
|
Assert.Empty(wire.Packets);
|
||||||
|
|
||||||
|
Send(device, PacketBuilder.Build(RioCommand.ResetRequest, new[] { (byte)RioResetTarget.Throttle }));
|
||||||
|
Assert.False(device.AnalogWedged);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Axis_values_clamp_to_wire_range()
|
||||||
|
{
|
||||||
|
var device = new VRioDevice();
|
||||||
|
device.SetAxis(RioAxis.Throttle, 60000);
|
||||||
|
Assert.Equal(AnalogCodec.Max, device.GetAxis(RioAxis.Throttle));
|
||||||
|
device.SetAxis(RioAxis.Throttle, -60000);
|
||||||
|
Assert.Equal(AnalogCodec.Min, device.GetAxis(RioAxis.Throttle));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user