Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
767473d1cf | ||
|
|
007d15e668 | ||
|
|
391c53f294 | ||
|
|
bec3bb1e4a | ||
|
|
6923c9f252 | ||
|
|
23204ba9c6 | ||
|
|
97e78b0eea | ||
|
|
ce5ed1117a | ||
|
|
41f6ef364d | ||
|
|
f77cd55b11 | ||
|
|
31c3a910f0 |
@@ -0,0 +1,50 @@
|
||||
---
|
||||
name: verify
|
||||
description: Build, launch, and drive the vRIO WinForms app to verify changes at the GUI surface — screenshots via PrintWindow, input via PostMessage, no focus stealing.
|
||||
---
|
||||
|
||||
# Verifying vRIO changes
|
||||
|
||||
Build + tests (tests are CI's job; run the app for verification):
|
||||
|
||||
```powershell
|
||||
dotnet build vrio.sln -v q -nologo # Debug
|
||||
# exe: src\VRio.App\bin\Debug\net48\VRio.App.exe
|
||||
```
|
||||
|
||||
## Driving the GUI without touching the user's desktop
|
||||
|
||||
This is the user's live desktop — **never** use SetForegroundWindow +
|
||||
SendKeys/mouse_event: focus-stealing prevention leaves the app behind
|
||||
VS Code and your clicks land in the user's windows. Instead:
|
||||
|
||||
- **Screenshots**: `PrintWindow(hwnd, dc, 2)` (PW_RENDERFULLCONTENT) —
|
||||
captures the window even when occluded. CopyFromScreen captures
|
||||
whatever is on top; don't use it.
|
||||
- **Canvas input** (stick drags, cell clicks): PostMessage
|
||||
`WM_LBUTTONDOWN/WM_MOUSEMOVE/WM_LBUTTONUP` straight to the canvas
|
||||
child HWND. Find it by descending `RealChildWindowFromPoint` from the
|
||||
form. Canvas geometry is compile-time constant (PanelCanvas.cs):
|
||||
CellW=66, BoxXY = x 416..496, y 6..74 (X/Y stick box).
|
||||
The **first** posted drag after launch can half-land (only the
|
||||
button-down registers); do a throwaway drag first, then the measured
|
||||
one, screenshot **while held** (spring-back recenters on up).
|
||||
- **Checkboxes/buttons**: WinForms controls expose no UIA TogglePattern
|
||||
(they surface as bare Panes) and don't answer BM_GETCHECK — but
|
||||
`SendMessage(hwnd, BM_CLICK, 0, 0)` works. Get the HWND from the UIA
|
||||
element's NativeWindowHandle (find by Name).
|
||||
- Pattern that works: Add-Type user32 P/Invokes, Start-Process the exe,
|
||||
drive via messages, PrintWindow screenshot to the scratchpad, kill
|
||||
the process in `finally`.
|
||||
|
||||
## Environment gotchas
|
||||
|
||||
- Bindings load from `%APPDATA%\vRIO\bindings.txt` (the user's file),
|
||||
not the built-in defaults — arrow keys are bound to Hat *buttons*,
|
||||
not the joystick axis. Drive axes via the X/Y box.
|
||||
- A real XInput gamepad is connected on this machine ("Controller #1
|
||||
connected"); the router only writes an axis when its composed value
|
||||
changes, so a centered pad won't fight a canvas drag.
|
||||
- The wire readout (top center, green) shows `GetWireAxis` values —
|
||||
joystick Y is natively negated on the wire (stick up = −80) unless
|
||||
"Invert Y" is checked.
|
||||
@@ -23,6 +23,17 @@ controls:
|
||||
- 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.
|
||||
- **Lamps can mirror onto an RGB keyboard** (*Mirror lamps on RGB keyboard*,
|
||||
via Windows 11 Dynamic Lighting): keys bound to lamp-capable buttons glow
|
||||
with the panel's palette and blink in step with the on-screen flash modes;
|
||||
per-key keyboards get the full button field, zone-lit keyboards show the
|
||||
strongest current lamp board-wide, and a picker narrows the mirror to one
|
||||
keyboard when several are attached. For the LEDs to stay lit while the
|
||||
*game* has focus, vRIO needs package identity: run `pkg\Register-vRIO.ps1`
|
||||
once (Developer Mode required; pass the exe folder for a deployed copy),
|
||||
launch vRIO from its **Start menu entry** (a direct exe launch runs without
|
||||
identity), then drag vRIO to the top of *Settings → Personalization →
|
||||
Dynamic Lighting → Background light control*.
|
||||
- **Keyboard and Xbox (XInput) controller input** drive the same controls
|
||||
through a bindings file (`%APPDATA%\vRIO\bindings.txt`, created with
|
||||
commented defaults on first run — *Edit bindings…* opens it, *Reload
|
||||
@@ -37,6 +48,17 @@ controls:
|
||||
F1–F6 / F7–F12 = the Secondary / Screen columns, numpad = internal
|
||||
keypad (hex keys on the operators), arrows + Space = hat + main, with
|
||||
ABXY / dpad / shoulders on the pad's named buttons.
|
||||
- **Capture one keyboard in the background** (*Capture keyboard* picker in the
|
||||
Input panel): by default keyboard input follows the focused window, so vRIO
|
||||
goes deaf the moment the sim takes focus. Selecting a specific keyboard
|
||||
instead taps it through the Windows **Raw Input** API (`RIDEV_INPUTSINK`), so
|
||||
its keys drive the panel *while the game has focus* — the input-side twin of
|
||||
the lamp mirror writing that keyboard's LEDs under the same condition. Raw
|
||||
Input observes without intercepting: the keystroke still reaches the focused
|
||||
app, so this is meant for a keyboard *dedicated* to the panel, not the one you
|
||||
also type on. Leaving the picker on *All keyboards* keeps the plain
|
||||
focus-only behavior. (No package identity needed — unlike the lamp side, this
|
||||
is a plain Win32 tap.)
|
||||
|
||||
## Wire behavior
|
||||
|
||||
@@ -60,11 +82,10 @@ device behavior grounded in the **real v4.2 firmware dump**
|
||||
- `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.
|
||||
- Optional **v4.2 reply-wedge emulation** (in `VRio.Core`; the UI toggles
|
||||
were removed): after retry exhaustion, analog requests are silently
|
||||
dropped — still ACK'd, RX path alive — until a host `ResetRequest`,
|
||||
reproducing the latch-leak fault the firmware analysis documents.
|
||||
|
||||
## Using it with RIOJoy on one PC
|
||||
|
||||
@@ -86,6 +107,7 @@ a null-modem cable work the same way.
|
||||
|------|----------|
|
||||
| `src/VRio.Core` | Protocol framing/builder/parser, the `VRioDevice` state machine, serial pump, panel layout data (class library) |
|
||||
| `src/VRio.App` | WinForms panel UI |
|
||||
| `pkg` | Sparse-package manifest + registration script: grants VRio.App.exe the package identity Dynamic Lighting's background-control list requires |
|
||||
| `tests/VRio.Core.Tests` | xUnit tests for the protocol + device engine |
|
||||
|
||||
## Building
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Sparse package ("package with external location"): grants the plain win32
|
||||
VRio.App.exe a package identity without changing how it is built or run.
|
||||
vRIO needs identity so Windows Dynamic Lighting can list it under
|
||||
Settings → Personalization → Dynamic Lighting → Background light control —
|
||||
without it the keyboard lamp mirror only works while vRIO has focus.
|
||||
|
||||
Register (Developer Mode, unsigned) with the exe's folder as the external
|
||||
location: see Register-vRIO.ps1 next to this file.
|
||||
-->
|
||||
<Package xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
|
||||
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
|
||||
xmlns:uap3="http://schemas.microsoft.com/appx/manifest/uap/windows10/3"
|
||||
xmlns:uap10="http://schemas.microsoft.com/appx/manifest/uap/windows10/10"
|
||||
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
|
||||
IgnorableNamespaces="uap uap3 uap10 rescap">
|
||||
|
||||
<!-- Bump Version on any Assets/resources.pri change: the shell's MrtCache
|
||||
is keyed by package full name and serves stale (even failed) icon
|
||||
lookups for a re-registered same-version package. -->
|
||||
<Identity Name="VWE.vRIO"
|
||||
ProcessorArchitecture="neutral"
|
||||
Publisher="CN=VWE"
|
||||
Version="1.0.0.1" />
|
||||
|
||||
<Properties>
|
||||
<DisplayName>vRIO</DisplayName>
|
||||
<PublisherDisplayName>VWE</PublisherDisplayName>
|
||||
<Logo>Assets\logo150.png</Logo>
|
||||
<uap10:AllowExternalContent>true</uap10:AllowExternalContent>
|
||||
</Properties>
|
||||
|
||||
<Dependencies>
|
||||
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.19041.0" MaxVersionTested="10.0.26200.0" />
|
||||
</Dependencies>
|
||||
|
||||
<Resources>
|
||||
<Resource Language="en-us" />
|
||||
</Resources>
|
||||
|
||||
<Capabilities>
|
||||
<rescap:Capability Name="runFullTrust" />
|
||||
<rescap:Capability Name="unvirtualizedResources" />
|
||||
</Capabilities>
|
||||
|
||||
<Applications>
|
||||
<Application Id="vRIO"
|
||||
Executable="VRio.App.exe"
|
||||
uap10:TrustLevel="mediumIL"
|
||||
uap10:RuntimeBehavior="win32App">
|
||||
<uap:VisualElements DisplayName="vRIO"
|
||||
Description="Virtual RIO cockpit device emulator"
|
||||
Square150x150Logo="Assets\logo150.png"
|
||||
Square44x44Logo="Assets\logo44.png"
|
||||
BackgroundColor="transparent" />
|
||||
<Extensions>
|
||||
<!-- Advertise as a lighting-controller app so Dynamic Lighting offers
|
||||
vRIO in Settings' "Background light control" picker. -->
|
||||
<uap3:Extension Category="windows.appExtension">
|
||||
<uap3:AppExtension Name="com.microsoft.windows.lighting"
|
||||
Id="vrio"
|
||||
DisplayName="vRIO"
|
||||
Description="vRIO cockpit lamp mirror"
|
||||
PublicFolder="Public" />
|
||||
</uap3:Extension>
|
||||
</Extensions>
|
||||
</Application>
|
||||
</Applications>
|
||||
</Package>
|
||||
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 696 B |
|
After Width: | Height: | Size: 696 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 744 B |
|
After Width: | Height: | Size: 744 B |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1,62 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Regenerates pkg\Assets\*.png from src\VRio.App\vwe.ico and rebuilds
|
||||
resources.pri — run after changing the icon artwork.
|
||||
|
||||
The shell resolves the manifest's Square44x44Logo through the package
|
||||
resource index; without resources.pri every icon lookup fails and the
|
||||
Start menu / taskbar show a generic icon. After regenerating, bump the
|
||||
Version in AppxManifest.xml (the shell's MrtCache is keyed by package
|
||||
full name and serves stale lookups otherwise) and re-register with
|
||||
Register-vRIO.ps1.
|
||||
|
||||
.PARAMETER MakePri
|
||||
Path to makepri.exe. Auto-detected from the Windows SDK if installed;
|
||||
otherwise extract it from the Microsoft.Windows.SDK.BuildTools NuGet
|
||||
package (it is a zip: bin\<ver>\x64\makepri.exe).
|
||||
#>
|
||||
param([string]$MakePri)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
|
||||
if (-not $MakePri) {
|
||||
$MakePri = Get-ChildItem 'C:\Program Files (x86)\Windows Kits\10\bin' -Recurse -Filter makepri.exe -ErrorAction SilentlyContinue |
|
||||
Where-Object FullName -like '*\x64\*' | Select-Object -First 1 -ExpandProperty FullName
|
||||
if (-not $MakePri) {
|
||||
throw 'makepri.exe not found — install the Windows SDK or pass -MakePri (see help).'
|
||||
}
|
||||
}
|
||||
|
||||
$assets = Join-Path $PSScriptRoot 'Assets'
|
||||
$ico = New-Object System.Drawing.Icon((Join-Path $PSScriptRoot '..\src\VRio.App\vwe.ico'), 32, 32)
|
||||
$src = $ico.ToBitmap()
|
||||
|
||||
function Save-Png([int]$canvas, [int]$content, [string]$name) {
|
||||
$bmp = New-Object System.Drawing.Bitmap($canvas, $canvas)
|
||||
$g = [System.Drawing.Graphics]::FromImage($bmp)
|
||||
$g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
|
||||
$g.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality
|
||||
$off = [int](($canvas - $content) / 2)
|
||||
$g.DrawImage($src, $off, $off, $content, $content)
|
||||
$g.Dispose()
|
||||
$bmp.Save((Join-Path $assets $name), [System.Drawing.Imaging.ImageFormat]::Png)
|
||||
$bmp.Dispose()
|
||||
Write-Host " $name"
|
||||
}
|
||||
|
||||
Save-Png 44 44 'logo44.png'
|
||||
Save-Png 150 96 'logo150.png' # medium tile: 3x upscale centered, crisper than full-bleed
|
||||
foreach ($ts in 16, 24, 32, 48) {
|
||||
# targetsize / altform-unplated variants are found by naming convention;
|
||||
# unplated is what the Win11 taskbar prefers.
|
||||
Save-Png $ts $ts "logo44.targetsize-$ts.png"
|
||||
Save-Png $ts $ts "logo44.targetsize-${ts}_altform-unplated.png"
|
||||
}
|
||||
$src.Dispose(); $ico.Dispose()
|
||||
|
||||
$cf = Join-Path $env:TEMP 'vrio-priconfig.xml'
|
||||
& $MakePri createconfig /cf $cf /dq en-US /o | Out-Null
|
||||
& $MakePri new /pr $PSScriptRoot /cf $cf /mn (Join-Path $PSScriptRoot 'AppxManifest.xml') /of (Join-Path $PSScriptRoot 'resources.pri') /o | Out-Null
|
||||
Remove-Item $cf
|
||||
Write-Host 'resources.pri rebuilt. Now bump Version in AppxManifest.xml and re-register.'
|
||||
@@ -0,0 +1 @@
|
||||
Public folder for the com.microsoft.windows.lighting app extension (required by the manifest schema).
|
||||
@@ -0,0 +1,60 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Grants VRio.App.exe package identity by registering the sparse package
|
||||
(pkg\AppxManifest.xml) with the exe's folder as the external location.
|
||||
|
||||
Identity is what lets Windows Dynamic Lighting offer vRIO under
|
||||
Settings > Personalization > Dynamic Lighting > "Background light control",
|
||||
so the keyboard lamp mirror keeps working while the game has focus.
|
||||
|
||||
Unsigned registration requires Developer Mode
|
||||
(Settings > System > For developers).
|
||||
|
||||
.PARAMETER ExePath
|
||||
Folder containing VRio.App.exe. Defaults to the repo's Release output;
|
||||
for a deployed zip, pass the extracted VRio folder.
|
||||
|
||||
.PARAMETER Unregister
|
||||
Remove the registration instead.
|
||||
#>
|
||||
param(
|
||||
[string]$ExePath = (Join-Path $PSScriptRoot '..\src\VRio.App\bin\Release\net48'),
|
||||
[switch]$Unregister
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$packageName = 'VWE.vRIO'
|
||||
|
||||
$existing = Get-AppxPackage -Name $packageName -ErrorAction SilentlyContinue
|
||||
if ($existing) {
|
||||
Write-Host "Removing existing registration $($existing.PackageFullName)..."
|
||||
Remove-AppxPackage -Package $existing.PackageFullName
|
||||
}
|
||||
if ($Unregister) {
|
||||
Write-Host 'Unregistered.'
|
||||
return
|
||||
}
|
||||
|
||||
$exe = Join-Path $ExePath 'VRio.App.exe'
|
||||
if (-not (Test-Path $exe)) { throw "VRio.App.exe not found in '$ExePath'" }
|
||||
$manifest = Join-Path $PSScriptRoot 'AppxManifest.xml'
|
||||
|
||||
# With AllowExternalContent the shell resolves the manifest's logo assets
|
||||
# against the EXTERNAL location, not the manifest's folder — the Start/taskbar
|
||||
# icon only shows if Assets\ and resources.pri sit next to the exe.
|
||||
$assetDir = New-Item -ItemType Directory -Force (Join-Path $ExePath 'Assets')
|
||||
Copy-Item (Join-Path $PSScriptRoot 'Assets\*') $assetDir -Force
|
||||
Copy-Item (Join-Path $PSScriptRoot 'resources.pri') $ExePath -Force
|
||||
|
||||
Add-AppxPackage -Register $manifest -ExternalLocation (Resolve-Path $ExePath).Path
|
||||
$family = (Get-AppxPackage -Name $packageName).PackageFamilyName
|
||||
Write-Host "Registered $packageName with external location '$ExePath'."
|
||||
Write-Host ''
|
||||
Write-Host 'IMPORTANT: identity is granted through shell activation only. Launch vRIO'
|
||||
Write-Host 'from the Start menu entry ("vRIO") or via:'
|
||||
Write-Host " explorer shell:AppsFolder\$family!vRIO"
|
||||
Write-Host 'Double-clicking VRio.App.exe runs it WITHOUT identity (lamp mirror then'
|
||||
Write-Host 'works only while vRIO has focus).'
|
||||
Write-Host ''
|
||||
Write-Host 'Then enable the lamp mirror and pick vRIO under Settings > Personalization >'
|
||||
Write-Host 'Dynamic Lighting > Background light control.'
|
||||
@@ -0,0 +1,431 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Windows.Forms;
|
||||
using VRio.Core.Device;
|
||||
using VRio.Core.Input;
|
||||
using VRio.Core.Panel;
|
||||
using VRio.Core.Protocol;
|
||||
using Windows.Devices.Enumeration;
|
||||
using Windows.Devices.Lights;
|
||||
using Windows.System;
|
||||
using Windows.UI;
|
||||
using Color = Windows.UI.Color;
|
||||
|
||||
namespace VRio.App;
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors the host-commanded lamp states onto per-key RGB keyboards through
|
||||
/// Windows Dynamic Lighting (<see cref="LampArray"/>). Keys bound to
|
||||
/// lamp-capable button addresses in the active profile glow with the panel's
|
||||
/// palette — the red family for most groups, yellow for Secondary/Screen —
|
||||
/// and the flash bits blink at the panel's rates; every other key is blacked
|
||||
/// out so the keyboard reads as the button field. Zone-lit keyboards (no
|
||||
/// per-key addressing, common on laptops) fall back to a whole-board mirror
|
||||
/// of the strongest current lamp state.
|
||||
///
|
||||
/// <para>Opt-in: enabling claims keyboard-kind lamp arrays (hot-plug aware),
|
||||
/// disabling releases them so Windows hands the LEDs back to the ambient
|
||||
/// scene — no explicit restore needed. With several keyboards attached,
|
||||
/// <see cref="SetTarget"/> narrows the mirror to one of them
|
||||
/// (<see cref="KeyboardsChanged"/> feeds the picker). Dynamic Lighting gives
|
||||
/// LED control to the <em>foreground</em> app by default; for the keys to
|
||||
/// stay lit while the game has focus, vRIO needs package identity and a pick
|
||||
/// under Settings → Personalization → Dynamic Lighting → "Background light
|
||||
/// control".</para>
|
||||
/// </summary>
|
||||
public sealed class KeyboardLampMirror : IDisposable
|
||||
{
|
||||
// Refresh cadence and flash phases match PanelCanvas, so the keyboard and
|
||||
// the on-screen panel blink in step.
|
||||
private const int RefreshMs = 100;
|
||||
|
||||
private static readonly Stopwatch Clock = Stopwatch.StartNew();
|
||||
|
||||
// Address → panel colouring, from the cockpit layout (yellow = the
|
||||
// Secondary/Screen columns, same test as PanelCanvas).
|
||||
private static readonly Dictionary<int, bool> LampAddressIsYellow = BuildAddressInfo();
|
||||
|
||||
private readonly VRioDevice _device;
|
||||
private readonly object _gate = new();
|
||||
private readonly Dictionary<string, (LampArray Array, bool PerKey)> _claimed = new();
|
||||
private readonly Dictionary<string, string> _known = new(); // every keyboard array seen, id → name
|
||||
|
||||
// One entry per lamp-capable bound key: which lamp drives it, how to paint it.
|
||||
private (int Address, VirtualKey Key, bool Yellow)[] _map = Array.Empty<(int, VirtualKey, bool)>();
|
||||
|
||||
private DeviceWatcher? _watcher;
|
||||
private System.Threading.Timer? _timer;
|
||||
private Color[]? _lastPushed; // parallel to _map; null forces a full repaint
|
||||
private Color _lastAggregate; // last whole-board color for zone keyboards
|
||||
private string? _target; // device id to mirror to; null = all keyboards
|
||||
private bool _enabled;
|
||||
private bool _unsupported; // WinRT/Dynamic Lighting missing — don't retry
|
||||
private bool _anyArraySeen; // a lamp-array device event arrived this session
|
||||
|
||||
public KeyboardLampMirror(VRioDevice device) =>
|
||||
_device = device ?? throw new ArgumentNullException(nameof(device));
|
||||
|
||||
/// <summary>Status/log lines (attach, detach, availability hints).</summary>
|
||||
public event Action<string>? Logged;
|
||||
|
||||
/// <summary>The set of detected keyboards changed (id, name) — for a picker.</summary>
|
||||
public event Action<IReadOnlyList<(string Id, string Name)>>? KeyboardsChanged;
|
||||
|
||||
/// <summary>Mirror on/off. Off releases the LEDs back to Windows.</summary>
|
||||
public bool Enabled
|
||||
{
|
||||
get { lock (_gate) return _enabled; }
|
||||
set
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_enabled == value || _unsupported)
|
||||
return;
|
||||
_enabled = value;
|
||||
}
|
||||
if (value) Start();
|
||||
else Stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Mirror to one keyboard by device id, or to all (null).</summary>
|
||||
public void SetTarget(string? deviceId)
|
||||
{
|
||||
LampArray[] release;
|
||||
List<(string Id, string Name)> reclaim = new();
|
||||
lock (_gate)
|
||||
{
|
||||
if (_target == deviceId)
|
||||
return;
|
||||
_target = deviceId;
|
||||
release = _claimed.Where(kv => !Matches(kv.Key))
|
||||
.Select(kv => kv.Value.Array).ToArray();
|
||||
foreach (string id in _claimed.Keys.Where(id => !Matches(id)).ToArray())
|
||||
_claimed.Remove(id);
|
||||
foreach (KeyValuePair<string, string> kv in _known)
|
||||
if (Matches(kv.Key) && !_claimed.ContainsKey(kv.Key))
|
||||
reclaim.Add((kv.Key, kv.Value));
|
||||
_lastPushed = null;
|
||||
}
|
||||
foreach (LampArray array in release)
|
||||
Release(array);
|
||||
foreach ((string id, string name) in reclaim)
|
||||
ReclaimAsync(id, name);
|
||||
}
|
||||
|
||||
/// <summary>Rebuild the key map from a loaded profile (lamp-capable buttons only).</summary>
|
||||
public void SetProfile(BindingProfile profile)
|
||||
{
|
||||
var map = new List<(int, VirtualKey, bool)>();
|
||||
var seen = new HashSet<VirtualKey>();
|
||||
foreach (KeyButtonBinding b in profile.KeyButtons)
|
||||
{
|
||||
if (!LampAddressIsYellow.TryGetValue(b.Address, out bool yellow))
|
||||
continue; // keypads and unknown addresses have no lamp
|
||||
if (!Enum.TryParse(b.Key, ignoreCase: true, out Keys key))
|
||||
continue;
|
||||
// WinForms Keys and WinRT VirtualKey share the Win32 VK number space.
|
||||
var vk = (VirtualKey)(int)(key & Keys.KeyCode);
|
||||
if (seen.Add(vk)) // first binding wins, like the router's key lookup
|
||||
map.Add((b.Address, vk, yellow));
|
||||
}
|
||||
lock (_gate)
|
||||
{
|
||||
_map = map.ToArray();
|
||||
_lastPushed = null; // repaint under the new map
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Enabled = false;
|
||||
Logged = null;
|
||||
KeyboardsChanged = null;
|
||||
}
|
||||
|
||||
// ---- Device lifecycle ---------------------------------------------------
|
||||
|
||||
private void Start()
|
||||
{
|
||||
try
|
||||
{
|
||||
var watcher = DeviceInformation.CreateWatcher(LampArray.GetDeviceSelector());
|
||||
watcher.Added += OnArrayAdded;
|
||||
watcher.Removed += OnArrayRemoved;
|
||||
watcher.Updated += (_, _) => { }; // required for the watcher to progress
|
||||
watcher.EnumerationCompleted += (_, _) =>
|
||||
{
|
||||
// Attaches log themselves (and may still be in flight); only the
|
||||
// empty case needs a line here.
|
||||
bool any;
|
||||
lock (_gate) any = _anyArraySeen;
|
||||
if (!any)
|
||||
Logged?.Invoke("Keyboard lighting: no Dynamic Lighting keyboard found (check Settings → Personalization → Dynamic Lighting)");
|
||||
};
|
||||
lock (_gate) _watcher = watcher;
|
||||
watcher.Start();
|
||||
// A leaked exception in a Timer callback kills the process — never
|
||||
// let a lighting hiccup take vRIO down mid-flight.
|
||||
_timer = new System.Threading.Timer(_ =>
|
||||
{
|
||||
try { Tick(); }
|
||||
catch (Exception ex) { Logged?.Invoke($"Keyboard lighting: paint failed: {ex.Message}"); }
|
||||
}, null, RefreshMs, RefreshMs);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_unsupported = true;
|
||||
_enabled = false;
|
||||
}
|
||||
Logged?.Invoke($"Keyboard lighting unavailable (Dynamic Lighting needs Windows 11): {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void Stop()
|
||||
{
|
||||
DeviceWatcher? watcher;
|
||||
LampArray[] arrays;
|
||||
lock (_gate)
|
||||
{
|
||||
watcher = _watcher;
|
||||
_watcher = null;
|
||||
arrays = _claimed.Values.Select(a => a.Array).ToArray();
|
||||
_claimed.Clear();
|
||||
_known.Clear();
|
||||
_lastPushed = null;
|
||||
}
|
||||
_timer?.Dispose();
|
||||
_timer = null;
|
||||
try { watcher?.Stop(); }
|
||||
catch (Exception ex) when (ex is InvalidOperationException or COMException) { }
|
||||
// Releasing the arrays hands the LEDs back to the system ambient scene.
|
||||
foreach (LampArray array in arrays)
|
||||
Release(array);
|
||||
RaiseKeyboardsChanged();
|
||||
}
|
||||
|
||||
private async void OnArrayAdded(DeviceWatcher sender, DeviceInformation info)
|
||||
{
|
||||
lock (_gate) _anyArraySeen = true;
|
||||
try
|
||||
{
|
||||
LampArray array = await LampArray.FromIdAsync(info.Id);
|
||||
if (array is null || array.LampArrayKind != LampArrayKind.Keyboard)
|
||||
return; // don't paint mice/strips/cases
|
||||
|
||||
lock (_gate) _known[info.Id] = info.Name;
|
||||
if (!TryClaim(info.Id, info.Name, array))
|
||||
Release(array);
|
||||
RaiseKeyboardsChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logged?.Invoke($"Keyboard lighting: could not open {info.Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void OnArrayRemoved(DeviceWatcher sender, DeviceInformationUpdate update)
|
||||
{
|
||||
string? name;
|
||||
LampArray? gone = null;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_known.TryGetValue(update.Id, out name))
|
||||
return;
|
||||
_known.Remove(update.Id);
|
||||
if (_claimed.TryGetValue(update.Id, out (LampArray Array, bool PerKey) entry))
|
||||
{
|
||||
gone = entry.Array;
|
||||
_claimed.Remove(update.Id);
|
||||
}
|
||||
}
|
||||
if (gone is not null)
|
||||
Release(gone);
|
||||
Logged?.Invoke($"Keyboard lighting: {name} disconnected");
|
||||
RaiseKeyboardsChanged();
|
||||
}
|
||||
|
||||
private async void ReclaimAsync(string id, string name)
|
||||
{
|
||||
try
|
||||
{
|
||||
LampArray array = await LampArray.FromIdAsync(id);
|
||||
if (array is null)
|
||||
return;
|
||||
if (!TryClaim(id, name, array))
|
||||
Release(array);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logged?.Invoke($"Keyboard lighting: could not reopen {name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Store the array if it is wanted right now; false = caller releases.</summary>
|
||||
private bool TryClaim(string id, string name, LampArray array)
|
||||
{
|
||||
bool perKey = array.SupportsVirtualKeys;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_enabled || !Matches(id) || _claimed.ContainsKey(id))
|
||||
return false;
|
||||
_claimed[id] = (array, perKey);
|
||||
_lastPushed = null; // full repaint including the base coat
|
||||
}
|
||||
Logged?.Invoke(perKey
|
||||
? $"Keyboard lighting: + {name} ({array.LampCount} LEDs, per-key)"
|
||||
: $"Keyboard lighting: + {name} ({array.LampCount} zones — no per-key map, mirroring the strongest lamp board-wide)");
|
||||
HookAvailability(array, name);
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool Matches(string id) => _target is null || _target == id;
|
||||
|
||||
private static void Release(LampArray array)
|
||||
{
|
||||
// No IDisposable projection on .NET Framework — drop the COM wrapper.
|
||||
try { Marshal.ReleaseComObject(array); } catch (ArgumentException) { }
|
||||
}
|
||||
|
||||
private void RaiseKeyboardsChanged()
|
||||
{
|
||||
(string, string)[] known;
|
||||
lock (_gate) known = _known.Select(kv => (kv.Key, kv.Value)).ToArray();
|
||||
KeyboardsChanged?.Invoke(known);
|
||||
}
|
||||
|
||||
private void HookAvailability(LampArray array, string name)
|
||||
{
|
||||
// IsAvailable/AvailabilityChanged need Windows 11 22H2+; older builds
|
||||
// just never get the background-control hint.
|
||||
try
|
||||
{
|
||||
bool lastAvailable = array.IsAvailable;
|
||||
if (!lastAvailable)
|
||||
Logged?.Invoke($"Keyboard lighting: Windows is withholding {name} — enable vRIO under " +
|
||||
"Settings → Personalization → Dynamic Lighting → Background light control");
|
||||
array.AvailabilityChanged += (a, _) =>
|
||||
{
|
||||
bool available = a.IsAvailable;
|
||||
if (available == lastAvailable)
|
||||
return; // the event also fires without a state change
|
||||
lastAvailable = available;
|
||||
Logged?.Invoke(available
|
||||
? $"Keyboard lighting: {name} available"
|
||||
: $"Keyboard lighting: {name} withheld (foreground app owns it — see Dynamic Lighting settings)");
|
||||
lock (_gate) _lastPushed = null; // repaint when control returns
|
||||
};
|
||||
}
|
||||
catch (Exception ex) when (ex is COMException or InvalidCastException or MissingMethodException) { }
|
||||
}
|
||||
|
||||
// ---- Rendering ----------------------------------------------------------
|
||||
|
||||
private void Tick()
|
||||
{
|
||||
(int Address, VirtualKey Key, bool Yellow)[] map;
|
||||
(LampArray Array, bool PerKey)[] arrays;
|
||||
Color[]? last;
|
||||
Color lastAggregate;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_enabled || _claimed.Count == 0)
|
||||
return;
|
||||
map = _map;
|
||||
arrays = _claimed.Values.ToArray();
|
||||
last = _lastPushed;
|
||||
lastAggregate = _lastAggregate;
|
||||
}
|
||||
|
||||
long tick = Clock.ElapsedMilliseconds;
|
||||
var colors = new Color[map.Length];
|
||||
var keys = new VirtualKey[map.Length];
|
||||
var bestShade = LampBrightness.Off;
|
||||
bool bestYellow = false;
|
||||
bool changed = last is null || last.Length != map.Length;
|
||||
for (int i = 0; i < map.Length; i++)
|
||||
{
|
||||
byte state = _device.GetLamp(map[i].Address);
|
||||
LampBrightness shade = RioLampState.Brightness(state);
|
||||
if (shade != LampBrightness.Off && !FlashPhaseOn(RioLampState.Flash(state), tick))
|
||||
shade = LampBrightness.Off;
|
||||
if (shade > bestShade)
|
||||
(bestShade, bestYellow) = (shade, map[i].Yellow);
|
||||
|
||||
colors[i] = Shade(shade, map[i].Yellow);
|
||||
keys[i] = map[i].Key;
|
||||
if (last is not null)
|
||||
changed |= !colors[i].Equals(last[i]);
|
||||
}
|
||||
Color aggregate = Shade(bestShade, bestYellow);
|
||||
changed |= !aggregate.Equals(lastAggregate);
|
||||
if (!changed)
|
||||
return;
|
||||
|
||||
foreach ((LampArray array, bool perKey) in arrays)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (perKey)
|
||||
{
|
||||
if (last is null) // new claim/map: black out the whole board first
|
||||
array.SetColor(ColorHelper.FromArgb(255, 0, 0, 0));
|
||||
if (map.Length > 0)
|
||||
array.SetColorsForKeys(colors, keys);
|
||||
}
|
||||
else if (map.Length > 0)
|
||||
{
|
||||
// Zone keyboard: the whole board is one big lamp showing
|
||||
// the strongest current state (idle dim, alerts bright/flash).
|
||||
array.SetColor(aggregate);
|
||||
}
|
||||
}
|
||||
catch (COMException) { } // device wobble; the watcher handles removal
|
||||
}
|
||||
lock (_gate)
|
||||
{
|
||||
_lastPushed = colors;
|
||||
_lastAggregate = aggregate;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The panel's lamp palette (PanelCanvas fills), as LED colors.</summary>
|
||||
private static Color Shade(LampBrightness shade, bool yellow) =>
|
||||
yellow
|
||||
? shade switch
|
||||
{
|
||||
LampBrightness.Bright => ColorHelper.FromArgb(255, 245, 210, 60),
|
||||
LampBrightness.Dim => ColorHelper.FromArgb(255, 140, 118, 38),
|
||||
_ => ColorHelper.FromArgb(255, 70, 60, 24),
|
||||
}
|
||||
: shade switch
|
||||
{
|
||||
LampBrightness.Bright => ColorHelper.FromArgb(255, 230, 70, 70),
|
||||
LampBrightness.Dim => ColorHelper.FromArgb(255, 120, 50, 50),
|
||||
_ => ColorHelper.FromArgb(255, 64, 40, 40),
|
||||
};
|
||||
|
||||
// Same half-periods as PanelCanvas, so panel and keyboard blink together.
|
||||
private static bool FlashPhaseOn(LampFlash flash, long tick)
|
||||
{
|
||||
long halfPeriod = flash switch
|
||||
{
|
||||
LampFlash.FlashSlow => 500,
|
||||
LampFlash.FlashMed => 250,
|
||||
LampFlash.FlashFast => 125,
|
||||
_ => 0,
|
||||
};
|
||||
return halfPeriod == 0 || tick / halfPeriod % 2 == 0;
|
||||
}
|
||||
|
||||
private static Dictionary<int, bool> BuildAddressInfo()
|
||||
{
|
||||
var info = new Dictionary<int, bool>();
|
||||
foreach (PanelButton b in CockpitLayout.Buttons())
|
||||
if (b.LampCapable)
|
||||
info[b.Address] = b.Group.Title is "Secondary" or "Screen";
|
||||
return info;
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,9 @@ internal sealed class MainForm : Form
|
||||
private readonly PanelCanvas _canvas = new();
|
||||
private readonly InputRouter _router;
|
||||
private readonly XInputGamepad _gamepad = new();
|
||||
private readonly KeyboardLampMirror _lampMirror;
|
||||
private readonly RawKeyboardSource _rawKeyboard = new();
|
||||
private string? _activeInputId; // interface path of the keyboard being captured; null = WinForms focus path
|
||||
private readonly string _bindingsPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "vRIO", "bindings.txt");
|
||||
|
||||
@@ -38,16 +41,13 @@ internal sealed class MainForm : Form
|
||||
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 _spring = new() { Text = "Stick springs back to center", Location = new Point(10, 24), AutoSize = true, Checked = true };
|
||||
private readonly Button _centerAxes = new() { Text = "Center all axes", Location = new Point(10, 52), Width = 140, Height = 26 };
|
||||
private readonly Button _lampsOff = new() { Text = "All lamps off", Location = new Point(156, 52), Width = 140, Height = 26 };
|
||||
|
||||
private readonly CheckBox _kbInput = new() { Text = "Keyboard", Location = new Point(10, 22), AutoSize = true, Checked = true };
|
||||
private readonly CheckBox _padInput = new() { Text = "Xbox gamepad", Location = new Point(120, 22), AutoSize = true, Checked = true };
|
||||
private readonly CheckBox _padInput = new() { Text = "Gamepad", Location = new Point(100, 22), AutoSize = true, Checked = true };
|
||||
private readonly CheckBox _invertY = new() { Text = "Invert Y", Location = new Point(190, 22), AutoSize = true };
|
||||
private readonly Label _padStatus = new()
|
||||
{
|
||||
Text = "No controller detected.",
|
||||
@@ -57,31 +57,61 @@ internal sealed class MainForm : Form
|
||||
};
|
||||
private readonly Button _reloadBindings = new() { Text = "Reload bindings", Location = new Point(10, 68), Width = 140, Height = 26 };
|
||||
private readonly Button _editBindings = new() { Text = "Edit bindings…", Location = new Point(156, 68), Width = 140, Height = 26 };
|
||||
private readonly CheckBox _kbLights = new()
|
||||
{
|
||||
Text = "Mirror lamps on RGB keyboard (Dynamic Lighting)",
|
||||
Location = new Point(10, 100),
|
||||
AutoSize = true,
|
||||
};
|
||||
private readonly ComboBox _kbLightsTarget = new()
|
||||
{
|
||||
Location = new Point(10, 122),
|
||||
Width = 286,
|
||||
DropDownStyle = ComboBoxStyle.DropDownList,
|
||||
Enabled = false, // populated while the mirror is on
|
||||
};
|
||||
private readonly Label _kbInputLabel = new()
|
||||
{
|
||||
Text = "Capture keyboard (no window focus needed):",
|
||||
Location = new Point(10, 144),
|
||||
AutoSize = true,
|
||||
};
|
||||
private readonly ComboBox _kbInputTarget = new()
|
||||
{
|
||||
Location = new Point(10, 162),
|
||||
Width = 286,
|
||||
DropDownStyle = ComboBoxStyle.DropDownList,
|
||||
};
|
||||
|
||||
/// <summary>A keyboard choice in the lamp-mirror picker (null id = all).</summary>
|
||||
private sealed record KbChoice(string? Id, string Name)
|
||||
{
|
||||
public override string ToString() => Name;
|
||||
}
|
||||
|
||||
private readonly Label _counters = new()
|
||||
{
|
||||
Location = new Point(12, 336),
|
||||
Location = new Point(12, 372),
|
||||
AutoSize = true,
|
||||
Font = new Font("Consolas", 8f),
|
||||
};
|
||||
|
||||
private readonly Label _help = new()
|
||||
{
|
||||
Location = new Point(12, 392),
|
||||
Location = new Point(12, 428),
|
||||
MaximumSize = new Size(306, 0),
|
||||
AutoSize = true,
|
||||
ForeColor = Color.Gray,
|
||||
Text = "Left-click a cell: momentary press. Right-click: latch it down. " +
|
||||
"Drag the X/Y box and the Z / L / R gauges to move the axes. " +
|
||||
"Keyboard and Xbox-pad input follow the bindings file (Edit bindings…).",
|
||||
"Drag the X/Y box and the Z / L / R gauges to move the axes.",
|
||||
};
|
||||
|
||||
private readonly TextBox _logBox = new()
|
||||
{
|
||||
Location = new Point(12, 476),
|
||||
Location = new Point(12, 498),
|
||||
Multiline = true,
|
||||
ReadOnly = true,
|
||||
ScrollBars = ScrollBars.Vertical,
|
||||
ScrollBars = ScrollBars.Both, // long wire lines don't wrap — scroll to read
|
||||
BackColor = Color.FromArgb(24, 24, 24),
|
||||
ForeColor = Color.Gainsboro,
|
||||
Font = new Font("Consolas", 8f),
|
||||
@@ -102,7 +132,10 @@ internal sealed class MainForm : Form
|
||||
|
||||
public MainForm()
|
||||
{
|
||||
Text = "vRIO — Virtual RIO cockpit device";
|
||||
// ProductVersion carries the git stamp (see StampGitVersion in the csproj).
|
||||
Text = $"vRIO v{Application.ProductVersion} — Virtual RIO cockpit device";
|
||||
// Title-bar/taskbar icon from the exe's embedded ApplicationIcon (vwe.ico).
|
||||
Icon = Icon.ExtractAssociatedIcon(Application.ExecutablePath);
|
||||
// Fit the window to its content: the cockpit canvas plus the 330px
|
||||
// control strip, with just enough height for the strip's log area.
|
||||
ClientSize = new Size(_canvas.Width + 332, Math.Max(_canvas.Height, 640));
|
||||
@@ -112,6 +145,7 @@ internal sealed class MainForm : Form
|
||||
|
||||
_service = new VRioSerialService(_device);
|
||||
_router = new InputRouter(_device);
|
||||
_lampMirror = new KeyboardLampMirror(_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) };
|
||||
@@ -123,6 +157,7 @@ internal sealed class MainForm : Form
|
||||
// Canvas ↔ device wiring.
|
||||
_canvas.LampProvider = _device.GetLamp;
|
||||
_canvas.AxisProvider = _device.GetAxis;
|
||||
_canvas.WireAxisProvider = _device.GetWireAxis;
|
||||
_canvas.AddressPressed += _device.PressAddress;
|
||||
_canvas.AddressReleased += _device.ReleaseAddress;
|
||||
_canvas.AxisMoved += (axis, value) => _device.SetAxis(axis, value);
|
||||
@@ -137,14 +172,13 @@ internal sealed class MainForm : Form
|
||||
_device.ResetReceived += _ => RunOnUi(_router.ResetAxisState);
|
||||
_device.Logged += line => RunOnUi(() => PrependLog(line));
|
||||
_service.Logged += line => RunOnUi(() => PrependLog(line));
|
||||
_lampMirror.Logged += line => RunOnUi(() => PrependLog(line));
|
||||
_service.ConnectionChanged += open => RunOnUi(() => OnConnectionChanged(open));
|
||||
_service.HostHandshake += high => RunOnUi(() =>
|
||||
PrependLog(high ? "Host raised DTR (board-reset handshake)" : "Host dropped DTR"));
|
||||
|
||||
_rescan.Click += (_, _) => RefreshPorts();
|
||||
_openClose.Click += (_, _) => ToggleOpen();
|
||||
_verMajor.ValueChanged += (_, _) => _device.VersionMajor = (byte)_verMajor.Value;
|
||||
_verMinor.ValueChanged += (_, _) => _device.VersionMinor = (byte)_verMinor.Value;
|
||||
_spring.CheckedChanged += (_, _) => _canvas.StickSpringsBack = _spring.Checked;
|
||||
_centerAxes.Click += (_, _) =>
|
||||
{
|
||||
@@ -157,8 +191,6 @@ internal sealed class MainForm : Form
|
||||
_device.ClearLamps();
|
||||
_canvas.Invalidate();
|
||||
};
|
||||
_testEnter.Click += (_, _) => _device.SendTestMode(1);
|
||||
_testExit.Click += (_, _) => _device.SendTestMode(0);
|
||||
_clearLog.Click += (_, _) => _logBox.Clear();
|
||||
|
||||
_kbInput.CheckedChanged += (_, _) =>
|
||||
@@ -166,6 +198,34 @@ internal sealed class MainForm : Form
|
||||
if (!_kbInput.Checked)
|
||||
_router.ReleaseAllKeys();
|
||||
};
|
||||
_invertY.CheckedChanged += (_, _) =>
|
||||
{
|
||||
_device.InvertJoystickY = _invertY.Checked;
|
||||
_canvas.Invalidate(); // the wire readout flips even though the axes didn't move
|
||||
};
|
||||
_device.InvertJoystickY = _invertY.Checked; // the field initializer raises no event
|
||||
_kbLights.CheckedChanged += (_, _) =>
|
||||
{
|
||||
_lampMirror.Enabled = _kbLights.Checked;
|
||||
_kbLightsTarget.Enabled = _kbLights.Checked;
|
||||
};
|
||||
_kbLightsTarget.SelectedIndexChanged += (_, _) =>
|
||||
_lampMirror.SetTarget((_kbLightsTarget.SelectedItem as KbChoice)?.Id);
|
||||
_lampMirror.KeyboardsChanged += list => RunOnUi(() => RebuildKeyboardPicker(list));
|
||||
_kbLightsTarget.Items.Add(new KbChoice(null, "All keyboards"));
|
||||
_kbLightsTarget.SelectedIndex = 0;
|
||||
|
||||
// Raw-input capture: a chosen keyboard drives the panel even while the
|
||||
// sim has focus. Down-edges only fire when keyboard input is enabled;
|
||||
// up-edges always land so a release is never stranded by a mid-hold flip.
|
||||
_rawKeyboard.KeyDown += name => RunOnUi(() => { if (_kbInput.Checked) _router.KeyDown(name); });
|
||||
_rawKeyboard.KeyUp += name => RunOnUi(() => _router.KeyUp(name));
|
||||
_rawKeyboard.Logged += line => RunOnUi(() => PrependLog(line));
|
||||
_rawKeyboard.KeyboardsChanged += list => RunOnUi(() => RebuildInputKeyboardPicker(list));
|
||||
_kbInputTarget.SelectedIndexChanged += (_, _) => ApplyInputSource();
|
||||
_kbInputTarget.DropDown += (_, _) => _rawKeyboard.RefreshKeyboards();
|
||||
_kbInputTarget.Items.Add(new KbChoice(null, "All keyboards (focus only)"));
|
||||
_kbInputTarget.SelectedIndex = 0;
|
||||
_reloadBindings.Click += (_, _) => LoadBindings();
|
||||
_editBindings.Click += (_, _) => OpenBindingsFile();
|
||||
|
||||
@@ -179,6 +239,8 @@ internal sealed class MainForm : Form
|
||||
{
|
||||
_uiTimer.Dispose();
|
||||
_inputTimer.Dispose();
|
||||
_lampMirror.Dispose();
|
||||
_rawKeyboard.Dispose();
|
||||
_service.Dispose();
|
||||
};
|
||||
|
||||
@@ -209,21 +271,17 @@ internal sealed class MainForm : Form
|
||||
panel.Controls.Add(_openClose);
|
||||
panel.Controls.Add(_linkStatus);
|
||||
|
||||
var device = new GroupBox { Text = "Device", Location = new Point(12, 68), Size = new Size(306, 150) };
|
||||
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 });
|
||||
var device = new GroupBox { Text = "Device", Location = new Point(12, 68), Size = new Size(306, 88) };
|
||||
device.Controls.AddRange(new Control[] { _spring, _centerAxes, _lampsOff });
|
||||
panel.Controls.Add(device);
|
||||
|
||||
var input = new GroupBox { Text = "Input", Location = new Point(12, 224), Size = new Size(306, 104) };
|
||||
input.Controls.AddRange(new Control[] { _kbInput, _padInput, _padStatus, _reloadBindings, _editBindings });
|
||||
var input = new GroupBox { Text = "Input", Location = new Point(12, 162), Size = new Size(306, 202) };
|
||||
input.Controls.AddRange(new Control[] { _kbInput, _padInput, _invertY, _padStatus, _reloadBindings, _editBindings, _kbLights, _kbLightsTarget, _kbInputLabel, _kbInputTarget });
|
||||
panel.Controls.Add(input);
|
||||
|
||||
panel.Controls.Add(_counters);
|
||||
panel.Controls.Add(_help);
|
||||
panel.Controls.Add(new Label { Text = "Wire log:", Location = new Point(12, 458), AutoSize = true });
|
||||
panel.Controls.Add(new Label { Text = "Wire log:", Location = new Point(12, 480), AutoSize = true });
|
||||
|
||||
_logBox.Size = new Size(306, ClientSize.Height - _logBox.Top - 44);
|
||||
panel.Controls.Add(_logBox);
|
||||
@@ -288,11 +346,35 @@ internal sealed class MainForm : Form
|
||||
|
||||
/// <summary>
|
||||
/// Keys route to the panel unless the user is in a control that needs
|
||||
/// them (port list, firmware spinners, log box scrolling).
|
||||
/// them (port list, log box scrolling).
|
||||
/// </summary>
|
||||
private bool KeyboardRoutingActive =>
|
||||
_kbInput.Checked &&
|
||||
!_portBox.ContainsFocus && !_verMajor.ContainsFocus && !_verMinor.ContainsFocus && !_logBox.ContainsFocus;
|
||||
!_portBox.ContainsFocus && !_logBox.ContainsFocus;
|
||||
|
||||
protected override void OnHandleCreated(EventArgs e)
|
||||
{
|
||||
base.OnHandleCreated(e);
|
||||
_rawKeyboard.Attach(Handle);
|
||||
_rawKeyboard.RefreshKeyboards(); // seed the capture picker
|
||||
}
|
||||
|
||||
protected override void WndProc(ref Message m)
|
||||
{
|
||||
// Raw Input arrives outside the normal key-message path; feed it to the
|
||||
// capture source, then let DefWindowProc run its WM_INPUT cleanup.
|
||||
const int WM_INPUT = 0x00FF, WM_INPUT_DEVICE_CHANGE = 0x00FE;
|
||||
switch (m.Msg)
|
||||
{
|
||||
case WM_INPUT:
|
||||
_rawKeyboard.ProcessInput(m.LParam);
|
||||
break;
|
||||
case WM_INPUT_DEVICE_CHANGE:
|
||||
_rawKeyboard.HandleDeviceChange();
|
||||
break;
|
||||
}
|
||||
base.WndProc(ref m);
|
||||
}
|
||||
|
||||
protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
|
||||
{
|
||||
@@ -306,7 +388,10 @@ internal sealed class MainForm : Form
|
||||
string name = (keyData & Keys.KeyCode).ToString();
|
||||
if (_router.HasKeyBinding(name))
|
||||
{
|
||||
_router.KeyDown(name);
|
||||
// While a specific keyboard is captured, that source owns
|
||||
// routing; still swallow the key so it can't click a button.
|
||||
if (!_rawKeyboard.IsCapturing)
|
||||
_router.KeyDown(name);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -315,18 +400,57 @@ internal sealed class MainForm : Form
|
||||
|
||||
protected override void OnKeyUp(KeyEventArgs e)
|
||||
{
|
||||
// Unconditional: the router ignores keys it never saw go down, and a
|
||||
// release must land even if the checkbox flipped mid-hold.
|
||||
_router.KeyUp(e.KeyCode.ToString());
|
||||
// Unconditional in focus mode: the router ignores keys it never saw go
|
||||
// down, and a release must land even if the checkbox flipped mid-hold.
|
||||
// In capture mode the raw source owns edges, so don't double-release.
|
||||
if (!_rawKeyboard.IsCapturing)
|
||||
_router.KeyUp(e.KeyCode.ToString());
|
||||
base.OnKeyUp(e);
|
||||
}
|
||||
|
||||
protected override void OnDeactivate(EventArgs e)
|
||||
{
|
||||
_router.ReleaseAllKeys(); // key-up events are lost once unfocused
|
||||
// Focus mode loses key-up events once unfocused, so release held keys.
|
||||
// Capture mode keeps receiving them in the background — leave holds be.
|
||||
if (!_rawKeyboard.IsCapturing)
|
||||
_router.ReleaseAllKeys();
|
||||
base.OnDeactivate(e);
|
||||
}
|
||||
|
||||
/// <summary>Apply the capture-picker selection: swap the keyboard input source.</summary>
|
||||
private void ApplyInputSource()
|
||||
{
|
||||
string? id = (_kbInputTarget.SelectedItem as KbChoice)?.Id;
|
||||
if (id == _activeInputId)
|
||||
return;
|
||||
_activeInputId = id;
|
||||
_router.ReleaseAllKeys(); // don't strand holds across a source swap
|
||||
_rawKeyboard.SetTarget(id);
|
||||
PrependLog(id is null
|
||||
? "Keyboard input: all keyboards, only while vRIO has focus"
|
||||
: $"Keyboard input: capturing \"{(_kbInputTarget.SelectedItem as KbChoice)?.Name}\" in the background");
|
||||
}
|
||||
|
||||
/// <summary>Refresh the capture picker, keeping the pick if the device survived.</summary>
|
||||
private void RebuildInputKeyboardPicker(IReadOnlyList<(string Id, string Name)> keyboards)
|
||||
{
|
||||
string? selected = (_kbInputTarget.SelectedItem as KbChoice)?.Id;
|
||||
_kbInputTarget.BeginUpdate();
|
||||
_kbInputTarget.Items.Clear();
|
||||
_kbInputTarget.Items.Add(new KbChoice(null, "All keyboards (focus only)"));
|
||||
int select = 0;
|
||||
foreach ((string id, string name) in keyboards)
|
||||
{
|
||||
int idx = _kbInputTarget.Items.Add(new KbChoice(id, name));
|
||||
if (id == selected)
|
||||
select = idx;
|
||||
}
|
||||
// A vanished capture device falls back to "All keyboards"; the
|
||||
// selection-changed handler (ApplyInputSource) unregisters accordingly.
|
||||
_kbInputTarget.SelectedIndex = select;
|
||||
_kbInputTarget.EndUpdate();
|
||||
}
|
||||
|
||||
private void InputTick()
|
||||
{
|
||||
double now = _clock.Elapsed.TotalSeconds;
|
||||
@@ -370,6 +494,7 @@ internal sealed class MainForm : Form
|
||||
|
||||
var profile = BindingProfileFormat.Parse(text, out var errors);
|
||||
_router.Profile = profile;
|
||||
_lampMirror.SetProfile(profile);
|
||||
foreach (string error in errors)
|
||||
PrependLog($"Bindings: {error}");
|
||||
PrependLog($"Bindings loaded: {profile.Count} ({_bindingsPath})");
|
||||
@@ -395,6 +520,26 @@ internal sealed class MainForm : Form
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Refresh the lamp-mirror keyboard picker, keeping the pick if it survived.</summary>
|
||||
private void RebuildKeyboardPicker(IReadOnlyList<(string Id, string Name)> keyboards)
|
||||
{
|
||||
string? selected = (_kbLightsTarget.SelectedItem as KbChoice)?.Id;
|
||||
_kbLightsTarget.BeginUpdate();
|
||||
_kbLightsTarget.Items.Clear();
|
||||
_kbLightsTarget.Items.Add(new KbChoice(null, "All keyboards"));
|
||||
int select = 0;
|
||||
foreach ((string id, string name) in keyboards)
|
||||
{
|
||||
int idx = _kbLightsTarget.Items.Add(new KbChoice(id, name));
|
||||
if (id == selected)
|
||||
select = idx;
|
||||
}
|
||||
// Falls back to "All keyboards" if the picked device vanished (the
|
||||
// selection-changed handler re-targets the mirror accordingly).
|
||||
_kbLightsTarget.SelectedIndex = select;
|
||||
_kbLightsTarget.EndUpdate();
|
||||
}
|
||||
|
||||
// ---- Status / log ------------------------------------------------------
|
||||
|
||||
private void UpdateStatus()
|
||||
|
||||
@@ -73,6 +73,12 @@ internal sealed class PanelCanvas : Control
|
||||
/// <summary>Current raw value of an axis (from the device).</summary>
|
||||
public Func<RioAxis, short>? AxisProvider { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Axis value as transmitted to the host (joystick Y sign flip applied).
|
||||
/// The text readout shows this; the dot and gauges track physical state.
|
||||
/// </summary>
|
||||
public Func<RioAxis, short>? WireAxisProvider { get; set; }
|
||||
|
||||
/// <summary>The user pressed a panel control.</summary>
|
||||
public event Action<int>? AddressPressed;
|
||||
|
||||
@@ -299,9 +305,10 @@ internal sealed class PanelCanvas : Control
|
||||
new Size(statusRect.Width, 0), TextFormatFlags.WordBreak).Height + 2;
|
||||
}
|
||||
|
||||
short WireAxis(RioAxis a) => WireAxisProvider?.Invoke(a) ?? Axis(a);
|
||||
string readout =
|
||||
$"Z {Axis(RioAxis.Throttle),4} L {Axis(RioAxis.LeftPedal),3} R {Axis(RioAxis.RightPedal),3} " +
|
||||
$"X {Axis(RioAxis.JoystickX),3} Y {Axis(RioAxis.JoystickY),3}";
|
||||
$"Z {WireAxis(RioAxis.Throttle),4} L {WireAxis(RioAxis.LeftPedal),3} R {WireAxis(RioAxis.RightPedal),3} " +
|
||||
$"X {WireAxis(RioAxis.JoystickX),3} Y {WireAxis(RioAxis.JoystickY),3}";
|
||||
TextRenderer.DrawText(g, readout, statusFont,
|
||||
new Rectangle(statusRect.X, readoutTop, statusRect.Width, statusRect.Bottom - readoutTop), green,
|
||||
TextFormatFlags.Left | TextFormatFlags.Top | TextFormatFlags.SingleLine);
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Windows.Forms;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace VRio.App;
|
||||
|
||||
/// <summary>
|
||||
/// A background keyboard input source built on the Win32 <b>Raw Input</b> API.
|
||||
/// Where the WinForms focus path (<c>ProcessCmdKey</c>/<c>OnKeyUp</c>) only sees
|
||||
/// keys while vRIO is the foreground window and can't tell one keyboard from
|
||||
/// another, Raw Input carries a per-device handle with every keystroke and —
|
||||
/// with the <c>RIDEV_INPUTSINK</c> flag — keeps delivering <c>WM_INPUT</c> even
|
||||
/// when vRIO is in the background. That lets the user nominate one physical
|
||||
/// keyboard as a dedicated cockpit panel: its keys drive the RIO controls while
|
||||
/// the sim (or RIOJoy) holds focus, and other keyboards are ignored.
|
||||
///
|
||||
/// <para>This is the input-side twin of <see cref="KeyboardLampMirror"/>: that
|
||||
/// class <em>writes</em> a chosen keyboard's LEDs while the game has focus, this
|
||||
/// one <em>reads</em> a chosen keyboard's keys under the same condition. Raw
|
||||
/// Input observes without intercepting — the keystroke still reaches whatever
|
||||
/// app has focus — so the selected keyboard should be one dedicated to the
|
||||
/// panel, not the one you also type on.</para>
|
||||
///
|
||||
/// <para>Registration is scoped to when a device is actually selected
|
||||
/// (<see cref="SetTarget"/>): picking "all keyboards" unregisters and hands
|
||||
/// input back to the focus path, so vRIO isn't pumping <c>WM_INPUT</c> for every
|
||||
/// keystroke system-wide when the feature is idle. Not thread-safe;
|
||||
/// <see cref="ProcessInput"/> and the events fire on the UI thread that owns the
|
||||
/// window handle, which is exactly where <see cref="Input.InputRouter"/> wants
|
||||
/// to be called.</para>
|
||||
/// </summary>
|
||||
public sealed class RawKeyboardSource : IDisposable
|
||||
{
|
||||
// --- Win32 Raw Input interop ---------------------------------------------
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct RAWINPUTDEVICE
|
||||
{
|
||||
public ushort usUsagePage;
|
||||
public ushort usUsage;
|
||||
public uint dwFlags;
|
||||
public IntPtr hwndTarget;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct RAWINPUTDEVICELIST
|
||||
{
|
||||
public IntPtr hDevice;
|
||||
public uint dwType;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct RAWINPUTHEADER
|
||||
{
|
||||
public uint dwType;
|
||||
public uint dwSize;
|
||||
public IntPtr hDevice;
|
||||
public IntPtr wParam;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct RAWKEYBOARD
|
||||
{
|
||||
public ushort MakeCode;
|
||||
public ushort Flags;
|
||||
public ushort Reserved;
|
||||
public ushort VKey;
|
||||
public uint Message;
|
||||
public uint ExtraInformation;
|
||||
}
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern bool RegisterRawInputDevices(
|
||||
[In] RAWINPUTDEVICE[] pRawInputDevices, uint uiNumDevices, uint cbSize);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern uint GetRawInputData(
|
||||
IntPtr hRawInput, uint uiCommand, IntPtr pData, ref uint pcbSize, uint cbSizeHeader);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern uint GetRawInputDeviceList(
|
||||
[In, Out] RAWINPUTDEVICELIST[]? pRawInputDeviceList, ref uint puiNumDevices, uint cbSize);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
private static extern uint GetRawInputDeviceInfoW(
|
||||
IntPtr hDevice, uint uiCommand, IntPtr pData, ref uint pcbSize);
|
||||
|
||||
private const uint Err = 0xFFFFFFFF; // sentinel: all three APIs return (UINT)-1 on failure
|
||||
private const uint RIDEV_REMOVE = 0x00000001;
|
||||
private const uint RIDEV_INPUTSINK = 0x00000100;
|
||||
private const uint RIDEV_DEVNOTIFY = 0x00002000; // WM_INPUT_DEVICE_CHANGE on hot-plug
|
||||
private const uint RID_INPUT = 0x10000003;
|
||||
private const uint RIDI_DEVICENAME = 0x20000007;
|
||||
private const uint RIM_TYPEKEYBOARD = 1;
|
||||
private const ushort RI_KEY_BREAK = 0x01; // set = key up (make/break)
|
||||
private const ushort HidUsagePageGeneric = 0x01;
|
||||
private const ushort HidUsageKeyboard = 0x06;
|
||||
|
||||
private static readonly int HeaderSize = Marshal.SizeOf<RAWINPUTHEADER>();
|
||||
|
||||
// --- State ---------------------------------------------------------------
|
||||
|
||||
// hDevice → interface path, so the hot per-keystroke path skips the
|
||||
// two-call GetRawInputDeviceInfo dance once a device is known.
|
||||
private readonly Dictionary<IntPtr, string> _pathByHandle = new();
|
||||
|
||||
private IntPtr _hwnd;
|
||||
private string? _target; // interface path of the keyboard to capture; null = disabled
|
||||
private bool _registered;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>A key on the captured keyboard went down (.NET <see cref="Keys"/> name).</summary>
|
||||
public event Action<string>? KeyDown;
|
||||
|
||||
/// <summary>A key on the captured keyboard came up (.NET <see cref="Keys"/> name).</summary>
|
||||
public event Action<string>? KeyUp;
|
||||
|
||||
/// <summary>The set of attached keyboards changed (interface path, display name) — for a picker.</summary>
|
||||
public event Action<IReadOnlyList<(string Id, string Name)>>? KeyboardsChanged;
|
||||
|
||||
/// <summary>Status/diagnostic lines.</summary>
|
||||
public event Action<string>? Logged;
|
||||
|
||||
/// <summary>True while a specific keyboard is being captured (focus path should stand down).</summary>
|
||||
public bool IsCapturing => _target is not null;
|
||||
|
||||
/// <summary>Bind to the window whose message loop delivers <c>WM_INPUT</c>.</summary>
|
||||
public void Attach(IntPtr hwnd) => _hwnd = hwnd;
|
||||
|
||||
/// <summary>
|
||||
/// Capture one keyboard by interface path, or none (<paramref name="deviceId"/>
|
||||
/// null) to release input back to the focus path. Registering/unregistering
|
||||
/// Raw Input is scoped here so vRIO only taps the global key stream while the
|
||||
/// feature is actually in use.
|
||||
/// </summary>
|
||||
public void SetTarget(string? deviceId)
|
||||
{
|
||||
if (_target == deviceId)
|
||||
return;
|
||||
_target = deviceId;
|
||||
if (deviceId is null)
|
||||
Unregister();
|
||||
else
|
||||
Register();
|
||||
}
|
||||
|
||||
/// <summary>Re-enumerate keyboards and raise <see cref="KeyboardsChanged"/>.</summary>
|
||||
public void RefreshKeyboards()
|
||||
{
|
||||
_pathByHandle.Clear(); // handles are only stable per connection
|
||||
KeyboardsChanged?.Invoke(EnumerateKeyboards());
|
||||
}
|
||||
|
||||
/// <summary>Handle a <c>WM_INPUT_DEVICE_CHANGE</c>: a keyboard arrived or left.</summary>
|
||||
public void HandleDeviceChange() => RefreshKeyboards();
|
||||
|
||||
/// <summary>
|
||||
/// Handle a <c>WM_INPUT</c> message (pass its <c>LParam</c>). Silently ignores
|
||||
/// input from any keyboard other than the captured one.
|
||||
/// </summary>
|
||||
public void ProcessInput(IntPtr hRawInput)
|
||||
{
|
||||
string? target = _target;
|
||||
if (target is null)
|
||||
return;
|
||||
|
||||
uint size = 0;
|
||||
if (GetRawInputData(hRawInput, RID_INPUT, IntPtr.Zero, ref size, (uint)HeaderSize) == Err || size == 0)
|
||||
return;
|
||||
|
||||
IntPtr buffer = Marshal.AllocHGlobal((int)size);
|
||||
try
|
||||
{
|
||||
if (GetRawInputData(hRawInput, RID_INPUT, buffer, ref size, (uint)HeaderSize) != size)
|
||||
return;
|
||||
|
||||
var header = Marshal.PtrToStructure<RAWINPUTHEADER>(buffer);
|
||||
if (header.dwType != RIM_TYPEKEYBOARD)
|
||||
return;
|
||||
string? path = PathFor(header.hDevice);
|
||||
if (!string.Equals(path, target, StringComparison.OrdinalIgnoreCase))
|
||||
return;
|
||||
|
||||
var kb = Marshal.PtrToStructure<RAWKEYBOARD>(IntPtr.Add(buffer, HeaderSize));
|
||||
ushort vkey = kb.VKey;
|
||||
// 0 = no VK, 0xFF = fake key emitted as part of an escaped sequence.
|
||||
if (vkey is 0 or 0xFF)
|
||||
return;
|
||||
|
||||
string key = ((Keys)vkey).ToString();
|
||||
if ((kb.Flags & RI_KEY_BREAK) != 0)
|
||||
KeyUp?.Invoke(key);
|
||||
else
|
||||
KeyDown?.Invoke(key); // repeats while held are dropped by the router
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FreeHGlobal(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
_disposed = true;
|
||||
_target = null;
|
||||
Unregister();
|
||||
KeyDown = null;
|
||||
KeyUp = null;
|
||||
KeyboardsChanged = null;
|
||||
Logged = null;
|
||||
}
|
||||
|
||||
// --- Registration --------------------------------------------------------
|
||||
|
||||
private void Register()
|
||||
{
|
||||
if (_registered || _hwnd == IntPtr.Zero)
|
||||
return;
|
||||
var rid = new[]
|
||||
{
|
||||
new RAWINPUTDEVICE
|
||||
{
|
||||
usUsagePage = HidUsagePageGeneric,
|
||||
usUsage = HidUsageKeyboard,
|
||||
dwFlags = RIDEV_INPUTSINK | RIDEV_DEVNOTIFY, // background delivery + hot-plug notices
|
||||
hwndTarget = _hwnd,
|
||||
},
|
||||
};
|
||||
if (RegisterRawInputDevices(rid, 1, (uint)Marshal.SizeOf<RAWINPUTDEVICE>()))
|
||||
_registered = true;
|
||||
else
|
||||
Logged?.Invoke($"Raw keyboard capture unavailable (error {Marshal.GetLastWin32Error()})");
|
||||
}
|
||||
|
||||
private void Unregister()
|
||||
{
|
||||
if (!_registered)
|
||||
return;
|
||||
// RIDEV_REMOVE must pass a null target window, or the call fails.
|
||||
var rid = new[]
|
||||
{
|
||||
new RAWINPUTDEVICE
|
||||
{
|
||||
usUsagePage = HidUsagePageGeneric,
|
||||
usUsage = HidUsageKeyboard,
|
||||
dwFlags = RIDEV_REMOVE,
|
||||
hwndTarget = IntPtr.Zero,
|
||||
},
|
||||
};
|
||||
RegisterRawInputDevices(rid, 1, (uint)Marshal.SizeOf<RAWINPUTDEVICE>());
|
||||
_registered = false;
|
||||
}
|
||||
|
||||
// --- Enumeration / naming ------------------------------------------------
|
||||
|
||||
private List<(string Id, string Name)> EnumerateKeyboards()
|
||||
{
|
||||
var result = new List<(string, string)>();
|
||||
uint listSize = (uint)Marshal.SizeOf<RAWINPUTDEVICELIST>();
|
||||
uint count = 0;
|
||||
if (GetRawInputDeviceList(null, ref count, listSize) == Err || count == 0)
|
||||
return result;
|
||||
|
||||
var devices = new RAWINPUTDEVICELIST[count];
|
||||
uint got = GetRawInputDeviceList(devices, ref count, listSize);
|
||||
if (got == Err)
|
||||
return result;
|
||||
|
||||
var labelCounts = new Dictionary<string, int>();
|
||||
for (int i = 0; i < got; i++)
|
||||
{
|
||||
if (devices[i].dwType != RIM_TYPEKEYBOARD)
|
||||
continue;
|
||||
string? path = PathFor(devices[i].hDevice);
|
||||
if (string.IsNullOrEmpty(path))
|
||||
continue;
|
||||
|
||||
string label = FriendlyName(path!);
|
||||
// Physical keyboards can surface as more than one collection; a bare
|
||||
// count suffix keeps otherwise-identical labels distinguishable.
|
||||
if (labelCounts.TryGetValue(label, out int n))
|
||||
{
|
||||
labelCounts[label] = n + 1;
|
||||
label = $"{label} ({n + 1})";
|
||||
}
|
||||
else
|
||||
{
|
||||
labelCounts[label] = 1;
|
||||
}
|
||||
result.Add((path!, label));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private string? PathFor(IntPtr hDevice)
|
||||
{
|
||||
if (_pathByHandle.TryGetValue(hDevice, out string? cached))
|
||||
return cached;
|
||||
string? path = DeviceName(hDevice);
|
||||
if (path is not null)
|
||||
_pathByHandle[hDevice] = path;
|
||||
return path;
|
||||
}
|
||||
|
||||
private static string? DeviceName(IntPtr hDevice)
|
||||
{
|
||||
uint chars = 0;
|
||||
if (GetRawInputDeviceInfoW(hDevice, RIDI_DEVICENAME, IntPtr.Zero, ref chars) == Err || chars == 0)
|
||||
return null;
|
||||
|
||||
IntPtr buffer = Marshal.AllocHGlobal((int)chars * sizeof(char));
|
||||
try
|
||||
{
|
||||
if (GetRawInputDeviceInfoW(hDevice, RIDI_DEVICENAME, buffer, ref chars) == Err)
|
||||
return null;
|
||||
return Marshal.PtrToStringUni(buffer);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FreeHGlobal(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Turn a raw device interface path into something a human recognizes: the
|
||||
/// device's registry description if we can resolve it, else its VID:PID.
|
||||
/// </summary>
|
||||
private static string FriendlyName(string interfacePath)
|
||||
{
|
||||
string? desc = RegistryName(interfacePath);
|
||||
if (!string.IsNullOrWhiteSpace(desc))
|
||||
return desc!;
|
||||
|
||||
Match m = Regex.Match(interfacePath, @"VID_([0-9A-Fa-f]{4}).*?PID_([0-9A-Fa-f]{4})");
|
||||
return m.Success
|
||||
? $"Keyboard {m.Groups[1].Value.ToUpperInvariant()}:{m.Groups[2].Value.ToUpperInvariant()}"
|
||||
: "Keyboard";
|
||||
}
|
||||
|
||||
private static string? RegistryName(string interfacePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
// \\?\HID#VID_x&PID_y#instance#{class-guid} → Enum key HID\VID_x&PID_y\instance
|
||||
string s = interfacePath;
|
||||
if (s.StartsWith(@"\\?\", StringComparison.Ordinal) || s.StartsWith(@"\\.\", StringComparison.Ordinal))
|
||||
s = s.Substring(4);
|
||||
int guid = s.LastIndexOf("#{", StringComparison.Ordinal);
|
||||
if (guid >= 0)
|
||||
s = s.Substring(0, guid);
|
||||
string instance = s.Replace('#', '\\');
|
||||
|
||||
using RegistryKey? key = Registry.LocalMachine.OpenSubKey(
|
||||
@"SYSTEM\CurrentControlSet\Enum\" + instance);
|
||||
if (key is null)
|
||||
return null;
|
||||
|
||||
string? name = key.GetValue("FriendlyName") as string;
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
name = key.GetValue("DeviceDesc") as string;
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return null;
|
||||
|
||||
// DeviceDesc is "@driver.inf,%token%;Actual Name" — keep the tail.
|
||||
int semi = name!.LastIndexOf(';');
|
||||
return semi >= 0 ? name.Substring(semi + 1) : name;
|
||||
}
|
||||
catch (Exception ex) when (ex is System.Security.SecurityException or UnauthorizedAccessException or IOException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,18 +8,47 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<ApplicationIcon>vwe.ico</ApplicationIcon>
|
||||
<AssemblyTitle>vRIO — Virtual RIO device</AssemblyTitle>
|
||||
<!-- StampGitVersion already puts the sha in InformationalVersion; stop the
|
||||
SDK appending "+fullsha" on top of it. -->
|
||||
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\VRio.Core\VRio.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Stamp commit date + short sha into InformationalVersion; the window
|
||||
title shows it (Application.ProductVersion) so a running build can be
|
||||
matched to its vYYYY.MM.DD release tag at a glance. Falls back to the
|
||||
default 1.0.0 when git isn't available. -->
|
||||
<Target Name="StampGitVersion" BeforeTargets="GetAssemblyVersion" Condition="'$(DesignTimeBuild)' != 'true'">
|
||||
<!-- %%cs survives cmd's percent expansion as %cs: committer date, YYYY-MM-DD. -->
|
||||
<Exec Command="git -C "$(MSBuildProjectDirectory)" log -1 --format=%%cs"
|
||||
ConsoleToMSBuild="true" StandardOutputImportance="low" IgnoreExitCode="true" ContinueOnError="true">
|
||||
<Output TaskParameter="ConsoleOutput" PropertyName="GitCommitDate" />
|
||||
<Output TaskParameter="ExitCode" PropertyName="GitDateExitCode" />
|
||||
</Exec>
|
||||
<!-- The exclude flag keeps describe off the release tags: always the short
|
||||
sha, with a "dirty" suffix when the working tree has local edits. -->
|
||||
<Exec Command="git -C "$(MSBuildProjectDirectory)" describe --always --dirty --exclude=*"
|
||||
ConsoleToMSBuild="true" StandardOutputImportance="low" IgnoreExitCode="true" ContinueOnError="true">
|
||||
<Output TaskParameter="ConsoleOutput" PropertyName="GitShortSha" />
|
||||
</Exec>
|
||||
<PropertyGroup Condition="'$(GitDateExitCode)' == '0' And '$(GitShortSha)' != ''">
|
||||
<InformationalVersion>$(GitCommitDate.Trim().Replace('-', '.')) ($(GitShortSha))</InformationalVersion>
|
||||
</PropertyGroup>
|
||||
</Target>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="PolySharp" Version="1.14.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<!-- WinRT projections for Windows.Devices.Lights (Dynamic Lighting /
|
||||
LampArray) on .NET Framework — the keyboard lamp mirror. -->
|
||||
<PackageReference Include="Microsoft.Windows.SDK.Contracts" Version="10.0.22621.3233" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -57,6 +57,15 @@ public sealed class VRioDevice
|
||||
/// <summary>Firmware version reported by VersionReply (real boards run 4.2).</summary>
|
||||
public byte VersionMinor { get; set; } = 2;
|
||||
|
||||
/// <summary>
|
||||
/// The wire natively carries stick-up as negative (full up = −80 in
|
||||
/// AnalogReply); set this to send the physical direction (up = positive)
|
||||
/// instead. Only the host sees the difference — local state
|
||||
/// (<see cref="GetAxis"/>, the panel's dot) keeps the physical stick
|
||||
/// direction either way.
|
||||
/// </summary>
|
||||
public bool InvertJoystickY { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When true, retry exhaustion leaves the analog reply path wedged (the
|
||||
/// v4.2 latch-leak bug) until a host ResetRequest clears it.
|
||||
@@ -101,6 +110,21 @@ public sealed class VRioDevice
|
||||
lock (_gate) return _axes[(int)axis];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Axis value as the next AnalogReply will carry it — same as
|
||||
/// <see cref="GetAxis"/> except joystick Y follows the wire convention
|
||||
/// (see <see cref="InvertJoystickY"/>).
|
||||
/// </summary>
|
||||
public short GetWireAxis(RioAxis axis)
|
||||
{
|
||||
short value = GetAxis(axis);
|
||||
return axis == RioAxis.JoystickY ? WireY(value) : value;
|
||||
}
|
||||
|
||||
private short WireY(short y) => InvertJoystickY
|
||||
? y
|
||||
: (short)Math.Min(AnalogCodec.Max, -y); // clamp: -Min (8192) is one past the 14-bit Max
|
||||
|
||||
/// <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.
|
||||
@@ -274,7 +298,7 @@ public sealed class VRioDevice
|
||||
y = _axes[(int)RioAxis.JoystickY];
|
||||
x = _axes[(int)RioAxis.JoystickX];
|
||||
}
|
||||
Send(PacketBuilder.AnalogReply(t, l, r, y, x));
|
||||
Send(PacketBuilder.AnalogReply(t, l, r, WireY(y), x));
|
||||
break;
|
||||
|
||||
case RioCommand.ResetRequest:
|
||||
|
||||
@@ -48,6 +48,7 @@ public class VRioDeviceTests
|
||||
var device = new VRioDevice();
|
||||
var wire = new Wire(device);
|
||||
device.SetAxis(RioAxis.Throttle, 1000);
|
||||
device.SetAxis(RioAxis.JoystickY, 3000);
|
||||
device.SetAxis(RioAxis.JoystickX, -5000);
|
||||
|
||||
Send(device, PacketBuilder.Build(RioCommand.AnalogRequest));
|
||||
@@ -60,11 +61,47 @@ public class VRioDeviceTests
|
||||
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(-3000, AnalogCodec.Combine(p[6], p[7])); // joystick Y (up is negative on the wire)
|
||||
Assert.Equal(-5000, AnalogCodec.Combine(p[8], p[9])); // joystick X
|
||||
Assert.Equal(1, device.AnalogRequests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Joystick_Y_is_negated_on_the_wire_but_not_locally()
|
||||
{
|
||||
var device = new VRioDevice();
|
||||
var wire = new Wire(device);
|
||||
device.SetAxis(RioAxis.JoystickY, 3000);
|
||||
|
||||
Send(device, PacketBuilder.Build(RioCommand.AnalogRequest));
|
||||
|
||||
byte[] p = Assert.Single(wire.Packets).Payload;
|
||||
Assert.Equal(-3000, AnalogCodec.Combine(p[6], p[7]));
|
||||
Assert.Equal(3000, device.GetAxis(RioAxis.JoystickY)); // the panel's dot is unflipped
|
||||
Assert.Equal(-3000, device.GetWireAxis(RioAxis.JoystickY)); // the panel's readout matches the wire
|
||||
|
||||
// Full negative deflection: -Min is one past Max, so it clamps rather than throws.
|
||||
device.SetAxis(RioAxis.JoystickY, AnalogCodec.Min);
|
||||
wire.Clear();
|
||||
Send(device, PacketBuilder.Build(RioCommand.AnalogRequest));
|
||||
p = Assert.Single(wire.Packets).Payload;
|
||||
Assert.Equal(AnalogCodec.Max, AnalogCodec.Combine(p[6], p[7]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvertJoystickY_sends_the_physical_direction_instead()
|
||||
{
|
||||
var device = new VRioDevice { InvertJoystickY = true };
|
||||
var wire = new Wire(device);
|
||||
device.SetAxis(RioAxis.JoystickY, 3000);
|
||||
|
||||
Send(device, PacketBuilder.Build(RioCommand.AnalogRequest));
|
||||
|
||||
byte[] p = Assert.Single(wire.Packets).Payload;
|
||||
Assert.Equal(3000, AnalogCodec.Combine(p[6], p[7]));
|
||||
Assert.Equal(3000, device.GetWireAxis(RioAxis.JoystickY));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VersionRequest_reports_configured_firmware()
|
||||
{
|
||||
|
||||