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