Mirror lamps onto RGB keyboards via Windows Dynamic Lighting

Opt-in checkbox in the Input group: keys bound to lamp-capable button
addresses glow with the panel's palette (red family, yellow for the
Secondary/Screen columns) and blink at the panel's own flash rates, so
keyboard and on-screen panel stay in step; unbound keys are blacked out
so the keyboard reads as the button field. Zone-lit keyboards without
per-key addressing (e.g. this laptop's 24-zone ITE board) fall back to
mirroring the strongest current lamp board-wide. A picker under the
checkbox narrows the mirror to one keyboard when several are attached
(hot-plug aware; releases the others back to Windows).

KeyboardLampMirror claims keyboard-kind LampArrays via a DeviceWatcher,
repaints at 100 ms only when colors actually change, survives paint
faults (a leaked Timer exception would kill the process), and logs
attach/detach plus IsAvailable transitions with a pointer to the
Dynamic Lighting background-control setting. Disabling releases every
claim so the LEDs revert to the ambient scene.

Also: the wire log gets a horizontal scrollbar (long lines don't wrap).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-06 15:08:38 -05:00
co-authored by Claude Fable 5
parent 1e79711181
commit 31c3a910f0
3 changed files with 495 additions and 7 deletions
+431
View File
@@ -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;
}
}
+61 -7
View File
@@ -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
};
/// <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, 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
}
}
/// <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()
+3
View File
@@ -20,6 +20,9 @@
<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>