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
+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()