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>
432 lines
16 KiB
C#
432 lines
16 KiB
C#
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;
|
|
}
|
|
}
|