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; } }