diff --git a/src/VRio.App/KeyboardLampMirror.cs b/src/VRio.App/KeyboardLampMirror.cs
new file mode 100644
index 0000000..44e91bf
--- /dev/null
+++ b/src/VRio.App/KeyboardLampMirror.cs
@@ -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;
+
+///
+/// Mirrors the host-commanded lamp states onto per-key RGB keyboards through
+/// Windows Dynamic Lighting (). 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.
+///
+/// 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,
+/// narrows the mirror to one of them
+/// ( feeds the picker). Dynamic Lighting gives
+/// LED control to the foreground 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".
+///
+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 LampAddressIsYellow = BuildAddressInfo();
+
+ private readonly VRioDevice _device;
+ private readonly object _gate = new();
+ private readonly Dictionary _claimed = new();
+ private readonly Dictionary _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));
+
+ /// Status/log lines (attach, detach, availability hints).
+ public event Action? Logged;
+
+ /// The set of detected keyboards changed (id, name) — for a picker.
+ public event Action>? KeyboardsChanged;
+
+ /// Mirror on/off. Off releases the LEDs back to Windows.
+ public bool Enabled
+ {
+ get { lock (_gate) return _enabled; }
+ set
+ {
+ lock (_gate)
+ {
+ if (_enabled == value || _unsupported)
+ return;
+ _enabled = value;
+ }
+ if (value) Start();
+ else Stop();
+ }
+ }
+
+ /// Mirror to one keyboard by device id, or to all (null).
+ 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 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);
+ }
+
+ /// Rebuild the key map from a loaded profile (lamp-capable buttons only).
+ public void SetProfile(BindingProfile profile)
+ {
+ var map = new List<(int, VirtualKey, bool)>();
+ var seen = new HashSet();
+ 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}");
+ }
+ }
+
+ /// Store the array if it is wanted right now; false = caller releases.
+ 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;
+ }
+ }
+
+ /// The panel's lamp palette (PanelCanvas fills), as LED colors.
+ 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 BuildAddressInfo()
+ {
+ var info = new Dictionary();
+ foreach (PanelButton b in CockpitLayout.Buttons())
+ if (b.LampCapable)
+ info[b.Address] = b.Group.Title is "Secondary" or "Screen";
+ return info;
+ }
+}
diff --git a/src/VRio.App/MainForm.cs b/src/VRio.App/MainForm.cs
index d133caa..6c210ef 100644
--- a/src/VRio.App/MainForm.cs
+++ b/src/VRio.App/MainForm.cs
@@ -19,6 +19,7 @@ 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 string _bindingsPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "vRIO", "bindings.txt");
@@ -57,17 +58,36 @@ 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
+ };
+
+ /// A keyboard choice in the lamp-mirror picker (null id = all).
+ private sealed record KbChoice(string? Id, string Name)
+ {
+ public override string ToString() => Name;
+ }
private readonly Label _counters = new()
{
- Location = new Point(12, 336),
+ Location = new Point(12, 390),
AutoSize = true,
Font = new Font("Consolas", 8f),
};
private readonly Label _help = new()
{
- Location = new Point(12, 392),
+ Location = new Point(12, 446),
MaximumSize = new Size(306, 0),
AutoSize = true,
ForeColor = Color.Gray,
@@ -78,10 +98,10 @@ internal sealed class MainForm : Form
private readonly TextBox _logBox = new()
{
- Location = new Point(12, 476),
+ Location = new Point(12, 530),
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),
@@ -112,6 +132,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) };
@@ -137,6 +158,7 @@ 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"));
@@ -166,6 +188,16 @@ internal sealed class MainForm : Form
if (!_kbInput.Checked)
_router.ReleaseAllKeys();
};
+ _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;
_reloadBindings.Click += (_, _) => LoadBindings();
_editBindings.Click += (_, _) => OpenBindingsFile();
@@ -179,6 +211,7 @@ internal sealed class MainForm : Form
{
_uiTimer.Dispose();
_inputTimer.Dispose();
+ _lampMirror.Dispose();
_service.Dispose();
};
@@ -217,13 +250,13 @@ internal sealed class MainForm : Form
device.Controls.AddRange(new Control[] { _spring, _centerAxes, _lampsOff, _testEnter, _testExit });
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, 224), Size = new Size(306, 158) };
+ input.Controls.AddRange(new Control[] { _kbInput, _padInput, _padStatus, _reloadBindings, _editBindings, _kbLights, _kbLightsTarget });
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, 512), AutoSize = true });
_logBox.Size = new Size(306, ClientSize.Height - _logBox.Top - 44);
panel.Controls.Add(_logBox);
@@ -370,6 +403,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 +429,26 @@ internal sealed class MainForm : Form
}
}
+ /// Refresh the lamp-mirror keyboard picker, keeping the pick if it survived.
+ private void RebuildKeyboardPicker(IReadOnlyList<(string Id, string Name)> keyboards)
+ {
+ string? selected = (_kbLightsTarget.SelectedItem as KbChoice)?.Id;
+ _kbLightsTarget.BeginUpdate();
+ _kbLightsTarget.Items.Clear();
+ _kbLightsTarget.Items.Add(new KbChoice(null, "All keyboards"));
+ int select = 0;
+ foreach ((string id, string name) in keyboards)
+ {
+ int idx = _kbLightsTarget.Items.Add(new KbChoice(id, name));
+ if (id == selected)
+ select = idx;
+ }
+ // Falls back to "All keyboards" if the picked device vanished (the
+ // selection-changed handler re-targets the mirror accordingly).
+ _kbLightsTarget.SelectedIndex = select;
+ _kbLightsTarget.EndUpdate();
+ }
+
// ---- Status / log ------------------------------------------------------
private void UpdateStatus()
diff --git a/src/VRio.App/VRio.App.csproj b/src/VRio.App/VRio.App.csproj
index 7114df3..e755852 100644
--- a/src/VRio.App/VRio.App.csproj
+++ b/src/VRio.App/VRio.App.csproj
@@ -20,6 +20,9 @@
all
runtime; build; native; contentfiles; analyzers; buildtransitive
+
+