From f77e41bd1710f460be48a13ef3d78dd1bfb5415b Mon Sep 17 00:00:00 2001 From: ivan tkachenko Date: Sat, 7 Mar 2026 00:31:30 +0200 Subject: [PATCH] Add integration with QMK/VIA keyboard on Framework Laptop 16 --- CHANGELOG.md | 1 + MuzikaGromche/MuzikaGromche.csproj | 2 + MuzikaGromche/Plugin.cs | 19 +- MuzikaGromche/PoweredLights.cs | 1 + MuzikaGromche/Via/AsyncEventProcessor.cs | 83 +++++ MuzikaGromche/Via/DevicePool.cs | 169 ++++++++++ MuzikaGromche/Via/Harness.cs | 231 ++++++++++++++ MuzikaGromche/Via/Lightshow.cs | 68 +++++ MuzikaGromche/Via/Unity.cs | 75 +++++ MuzikaGromche/Via/Via.cs | 374 +++++++++++++++++++++++ 10 files changed, 1022 insertions(+), 1 deletion(-) create mode 100644 MuzikaGromche/Via/AsyncEventProcessor.cs create mode 100644 MuzikaGromche/Via/DevicePool.cs create mode 100644 MuzikaGromche/Via/Harness.cs create mode 100644 MuzikaGromche/Via/Lightshow.cs create mode 100644 MuzikaGromche/Via/Unity.cs create mode 100644 MuzikaGromche/Via/Via.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index b4500bd..884f8c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## MuzikaGromche 1337.9001.69 - Show real Artist & Song info in the config. +- Integrated visual effects with QMK/VIA RGB keyboard (specifically input modules for Framework Laptop 16). ## MuzikaGromche 1337.9001.68 - LocalHost hotfix diff --git a/MuzikaGromche/MuzikaGromche.csproj b/MuzikaGromche/MuzikaGromche.csproj index cc7ea5b..e18b8ff 100644 --- a/MuzikaGromche/MuzikaGromche.csproj +++ b/MuzikaGromche/MuzikaGromche.csproj @@ -47,6 +47,7 @@ + @@ -91,6 +92,7 @@ + diff --git a/MuzikaGromche/Plugin.cs b/MuzikaGromche/Plugin.cs index a91ce8a..6ec5fd1 100644 --- a/MuzikaGromche/Plugin.cs +++ b/MuzikaGromche/Plugin.cs @@ -170,6 +170,7 @@ namespace MuzikaGromche Harmony.PatchAll(typeof(DeathScreenGameOverTextResetPatch)); Harmony.PatchAll(typeof(ScreenFiltersManager.HUDManagerScreenFiltersPatch)); Harmony.PatchAll(typeof(ClearAudioClipCachePatch)); + Harmony.PatchAll(typeof(Via.ViaFlickerLightsPatch)); NetcodePatcher(); Compatibility.Register(this); } @@ -1328,6 +1329,7 @@ namespace MuzikaGromche { // Calculate final color, substituting null with initialColor if needed. public abstract Color GetColor(Color initialColor); + public abstract Color? GetNullableColor(); protected string NullableColorToString(Color? color) { @@ -1344,6 +1346,11 @@ namespace MuzikaGromche return Color ?? initialColor; } + public override Color? GetNullableColor() + { + return Color; + } + public override string ToString() { return $"Color(#{NullableColorToString(Color)})"; @@ -1365,7 +1372,7 @@ namespace MuzikaGromche return Color.Lerp(from, to, Mathf.Clamp(Easing.Eval(T), 0f, 1f)); } - private Color? GetNullableColor() + public override Color? GetNullableColor() { return From is { } from && To is { } to ? Color.Lerp(from, to, Mathf.Clamp(Easing.Eval(T), 0f, 1f)) : null; } @@ -2340,6 +2347,7 @@ namespace MuzikaGromche internal void Stop() { PoweredLightsBehaviour.Instance.ResetLightColor(); + Via.ViaBehaviour.Instance.Restore(); DiscoBallManager.Disable(); ScreenFiltersManager.Clear(); @@ -2549,9 +2557,18 @@ namespace MuzikaGromche case SetLightsColorEvent e: PoweredLightsBehaviour.Instance.SetLightColor(e); + if (localPlayerCanHearMusic && e.GetNullableColor() is { } color) + { + Via.ViaBehaviour.Instance.SetColor(color); + } + else + { + Via.ViaBehaviour.Instance.Restore(); + } break; case FlickerLightsEvent: + // VIA is handled by a Harmony patch to integrate with all flickering events, not just from this mod. RoundManager.Instance.FlickerLights(true); break; diff --git a/MuzikaGromche/PoweredLights.cs b/MuzikaGromche/PoweredLights.cs index 2665d0a..983cf4f 100644 --- a/MuzikaGromche/PoweredLights.cs +++ b/MuzikaGromche/PoweredLights.cs @@ -1,5 +1,6 @@ using DunGen; using HarmonyLib; +using MuzikaGromche.Via; using System; using System.Collections.Generic; using System.IO; diff --git a/MuzikaGromche/Via/AsyncEventProcessor.cs b/MuzikaGromche/Via/AsyncEventProcessor.cs new file mode 100644 index 0000000..c2a1329 --- /dev/null +++ b/MuzikaGromche/Via/AsyncEventProcessor.cs @@ -0,0 +1,83 @@ +using System.Threading.Tasks; + +namespace MuzikaGromche.Via; + +public sealed class AsyncEventProcessor where T : struct +{ + private readonly object stateLock = new(); + + private T latestData; + private T lastProcessedData; + private bool isShutdownRequested; + private TaskCompletionSource signal = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public delegate ValueTask ProcessEventAsync(T oldData, T newData, bool shutdown); + private readonly ProcessEventAsync processEventAsync; + + public AsyncEventProcessor(T initialData, ProcessEventAsync processEventAsync) + { + latestData = initialData; + lastProcessedData = initialData; + this.processEventAsync = processEventAsync; + _ = Task.Run(ProcessLoopAsync); + } + + /// + /// Signals the processor that new data is available. + /// If requestShutdown is set, the processor will perform one last pass before exiting. + /// + public void Notify(T data, bool requestShutdown = false) + { + lock (stateLock) + { + latestData = data; + if (requestShutdown) + { + isShutdownRequested = true; + } + // Trigger the task to wake up + signal.TrySetResult(true); + } + } + + private async Task ProcessLoopAsync() + { + bool running = true; + + while (running) + { + Task nextSignal; + + lock (stateLock) + { + nextSignal = signal.Task; + } + + // Wait for a notification or shutdown signal + // + // VSTHRD003 fix: We are awaiting a task we didn't "start", + // but by using RunContinuationsAsynchronously in the TCS constructor, + // we guarantee the 'await' won't hijack the signaler's thread. + await nextSignal.ConfigureAwait(false); + + T newData; + T oldData; + + // Reset the signal for the next round + lock (stateLock) + { + signal = new(TaskCreationOptions.RunContinuationsAsynchronously); + if (isShutdownRequested) + { + running = false; + } + + newData = latestData; + oldData = lastProcessedData; + lastProcessedData = newData; + } + + await processEventAsync(oldData, newData, !running); + } + } +} diff --git a/MuzikaGromche/Via/DevicePool.cs b/MuzikaGromche/Via/DevicePool.cs new file mode 100644 index 0000000..7a572a5 --- /dev/null +++ b/MuzikaGromche/Via/DevicePool.cs @@ -0,0 +1,169 @@ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using HidSharp; + +namespace MuzikaGromche.Via; + +class DevicePool : IDisposable + where T : IDevicePoolDelegate +{ + private readonly IDevicePoolFactory sidecarFactory; + // Async synchronization + private readonly SemaphoreSlim semaphore; + // Async access, use semaphore! + private readonly Dictionary existing = []; + + private bool updating = false; + + public DevicePool(IDevicePoolFactory sidecarFactory) + { + semaphore = new SemaphoreSlim(1, 1); + this.sidecarFactory = sidecarFactory; + DeviceList.Local.Changed += (_sender, _args) => + { + Console.WriteLine($"Pool Changed"); + _ = Task.Run(async () => + { + await Task.Delay(300); + if (!updating) + { + updating = true; + UpdateConnections(); + updating = false; + return; + } + }); + }; + UpdateConnections(); + } + + private void WithSemaphore(Action action) + { + semaphore.Wait(); + try + { + action.Invoke(); + } + finally + { + semaphore.Release(); + } + } + + public void Dispose() + { + Console.WriteLine($"Pool Dispose"); + Clear(); + } + + private void Clear() + { + WithSemaphore(ClearUnsafe); + } + + private void ClearUnsafe() + { + Console.WriteLine($"Pool Clear"); + ForEachUnsafe((sidecar) => sidecar.Dispose()); + existing.Clear(); + } + + private void ClearUnsafe(string path) + { + if (existing.Remove(path, out var data)) + { + var (_, sidecar) = data; + sidecar.Dispose(); + } + } + + public void Restore() + { + Console.WriteLine($"Pool Restore"); + ForEach((sidecar) => sidecar.Restore()); + } + + private void UpdateConnections() + { + WithSemaphore(() => + { + // set of removed devices to be cleaned up + var removed = existing.Keys.ToHashSet(); + + foreach (var hidDevice in DeviceList.Local.GetHidDevices()) + { + try + { + // surely path is enough to uniquely differentiate + var path = hidDevice.DevicePath; + if (existing.ContainsKey(path)) + { + // not gone anywhere + removed.Remove(path); + continue; + } + + var sidecar = sidecarFactory.Create(hidDevice); + if (sidecar != null) + { + Console.WriteLine($"Pool Added {path}"); + existing[path] = (hidDevice, sidecar); + } + } + catch (Exception) + { + } + } + foreach (var path in removed) + { + try + { + ClearUnsafe(path); + } + catch (Exception) + { + } + } + }); + } + + public void ForEach(Action action) + { + WithSemaphore(() => ForEachUnsafe(action)); + } + + private void ForEachUnsafe(Action action) + { + foreach (var (_, sidecar) in existing.Values) + { + try + { + action(sidecar); + } + // ignore. the faulty device will be removed soon. + catch (IOException) + { + } + catch (TimeoutException) + { + } + } + } + +} + +/// Dispose should call Restore +interface IDevicePoolDelegate : IDisposable +{ + void Restore(); +} + +interface IDevicePoolFactory +{ + T? Create(HidDevice hidDevice); +} diff --git a/MuzikaGromche/Via/Harness.cs b/MuzikaGromche/Via/Harness.cs new file mode 100644 index 0000000..be15627 --- /dev/null +++ b/MuzikaGromche/Via/Harness.cs @@ -0,0 +1,231 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using HidSharp; + +namespace MuzikaGromche.Via; + +class FrameworkDeviceFactory : IDevicePoolFactory +{ + // Framework Vendor ID + private const int VID = 0x32AC; + internal static readonly int[] PIDs = + [ + 0x0012, // Framework Laptop 16 Keyboard Module - ANSI + 0x0013, // Framework Laptop 16 RGB macropad module + ]; + // VIA/QMK Raw HID identifiers + private const ushort UsagePage = 0xFF60; + private const byte Usage = 0x61; + + // In case of Framework Laptop 16 RGB Keyboard and Macropad, + // Usage Page & Usage are encoded at the start of the report descriptor. + private static readonly byte[] UsagePageAndUsageEncoded = [0x06, 0x60, 0xFF, 0x09, 0x61]; + + private const byte ExpectedVersion = 12; + + private static bool DevicePredicate(HidDevice device) + { + if (VID != device.VendorID) + { + return false; + } + if (!PIDs.Contains(device.ProductID)) + { + return false; + } + var report = device.GetRawReportDescriptor(); + if (report == null) + { + return false; + } + if (!report.AsSpan().StartsWith(UsagePageAndUsageEncoded)) + { + return false; + } + return true; + } + + public ViaDeviceDelegate? Create(HidDevice hidDevice) + { + if (!DevicePredicate(hidDevice)) + { + return null; + } + if (!hidDevice.TryOpen(out var stream)) + { + return null; + } + var via = new ViaKeyboardApi(stream); + var version = via.GetProtocolVersion(); + if (version != ExpectedVersion) + { + return null; + } + var brightness = via.GetRgbMatrixBrightness(); + if (brightness == null) + { + return null; + } + var effect = via.GetRgbMatrixEffect(); + if (effect == null) + { + return null; + } + var color = via.GetRgbMatrixColor(); + if (color == null) + { + return null; + } + + return new ViaDeviceDelegate(hidDevice, stream, via, brightness.Value, effect.Value, color.Value.Hue, color.Value.Saturation); + } +} + +class ViaDeviceDelegate : IDevicePoolDelegate, ILightshow, IDisposable +{ + // Objects + public readonly HidDevice device; + public readonly HidStream stream; + public readonly ViaKeyboardApi via; + + private readonly AsyncEventProcessor asyncEventProcessor; + private readonly ViaDeviceState initialState; + private byte? brightnessOverride = null; + private const ViaEffectMode EFFECT = ViaEffectMode.Static; + private byte? effectOverride = null; + private ColorHSV? color = null; + + public ViaDeviceDelegate(HidDevice device, HidStream stream, ViaKeyboardApi via, byte brightness, byte effect, byte hue, byte saturation) + { + // Objects + this.device = device; + this.stream = stream; + this.via = via; + // Initial values + initialState = new() + { + Brightness = brightness, + Effect = effect, + Hue = hue, + Saturation = saturation, + }; + asyncEventProcessor = new AsyncEventProcessor(initialState, ProcessDataAsync); + Notify(); + } + + public void Restore() + { + brightnessOverride = null; + effectOverride = null; + color = null; + Notify(); + } + + public void Dispose() + { + // Simplified version of Restore() + brightnessOverride = null; + effectOverride = null; + color = null; + asyncEventProcessor.Notify(initialState, requestShutdown: true); + } + + public void SetBrightnessOverride(byte? brightness) + { + brightnessOverride = brightness; + effectOverride = (byte)EFFECT; + Notify(); + } + + public void SetColor(byte hue, byte saturation, byte value) + { + color = new ColorHSV(hue, saturation, value); + effectOverride = (byte)EFFECT; + Notify(); + } + + private void Notify() + { + asyncEventProcessor.Notify(new() + { + Brightness = brightnessOverride ?? color?.Value ?? initialState.Brightness, + Effect = effectOverride ?? initialState.Effect, + Hue = color?.Hue ?? initialState.Hue, + Saturation = color?.Saturation ?? initialState.Saturation, + }); + } + + private async ValueTask ProcessDataAsync(ViaDeviceState oldData, ViaDeviceState newData, bool shutdown) + { + var cancellationToken = CancellationToken.None; + try + { + if (oldData.Brightness != newData.Brightness) + { + await via.SetRgbMatrixBrightnessAsync(newData.Brightness, cancellationToken); + } + if (oldData.Effect != newData.Effect) + { + await via.SetRgbMatrixEffectAsync(newData.Effect, cancellationToken); + } + if (oldData.Hue != newData.Hue || oldData.Saturation != newData.Saturation) + { + await via.SetRgbMatrixColorAsync(newData.Hue, newData.Saturation, cancellationToken); + } + } + catch (IOException) + { + } + catch (TimeoutException) + { + } + finally + { + if (shutdown) + { + stream.Close(); + } + } + } +} + +class DevicePoolLightshow : ILightshow + where T: IDevicePoolDelegate, ILightshow +{ + private readonly DevicePool devicePool; + + public DevicePoolLightshow(DevicePool devicePool) + { + this.devicePool = devicePool; + } + + public void SetBrightnessOverride(byte? brightness) + { + devicePool.ForEach(d => + { + d.SetBrightnessOverride(brightness); + }); + } + + public void SetColor(byte hue, byte saturation, byte value) + { + devicePool.ForEach(d => + { + d.SetColor(hue, saturation, value); + }); + } +} + +struct ViaDeviceState +{ + public byte Brightness; + public byte Effect; + public byte Hue; + public byte Saturation; +} + +record struct ColorHSV(byte Hue, byte Saturation, byte Value); diff --git a/MuzikaGromche/Via/Lightshow.cs b/MuzikaGromche/Via/Lightshow.cs new file mode 100644 index 0000000..d2e6615 --- /dev/null +++ b/MuzikaGromche/Via/Lightshow.cs @@ -0,0 +1,68 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace MuzikaGromche.Via; + +interface ILightshow +{ + /// + /// Override or reset brightness (value) for flickering animation. + /// + void SetBrightnessOverride(byte? brightness); + void SetColor(byte hue, byte saturation, byte value); +} + +class Animations +{ + private static CancellationTokenSource? cts; + + public static void Flicker(ILightshow lightshow) + { + if (cts != null) + { + cts.Cancel(); + } + cts = new(); + _ = Task.Run(async () => await FlickerAsync(lightshow, cts.Token), cts.Token); + } + + static async ValueTask FlickerAsync(ILightshow lightshow, CancellationToken cancellationToken) + { + await foreach (var on in FlickerAnimationAsync()) + { + if (cancellationToken.IsCancellationRequested) + { + break; + } + byte brightness = on ? (byte)0xFF : (byte)0x00; + lightshow.SetBrightnessOverride(brightness); + } + lightshow.SetBrightnessOverride(null); + } + + // Timestamps (in frames of 60 fps) of state switches, starting with 0 => ON. + private static readonly int[] MansionWallLampFlicker = [ + 0, 4, 8, 26, 28, 32, 37, 41, 42, 58, 60, 71, + ]; + + public static async IAsyncEnumerable FlickerAnimationAsync() + { + bool lastState = false; + int lastFrame = 0; + + const int initialMillisecondsDelay = 4 * 1000 / 60; + await Task.Delay(initialMillisecondsDelay); + + foreach (int frame in MansionWallLampFlicker) + { + // convert difference in frames into milliseconds + int millisecondsDelay = (int)((frame - lastFrame) / 60f * 1000f); + lastState = !lastState; + lastFrame = frame; + + await Task.Delay(millisecondsDelay); + yield return lastState; + } + } +} diff --git a/MuzikaGromche/Via/Unity.cs b/MuzikaGromche/Via/Unity.cs new file mode 100644 index 0000000..0df492d --- /dev/null +++ b/MuzikaGromche/Via/Unity.cs @@ -0,0 +1,75 @@ +using HarmonyLib; +using UnityEngine; + +namespace MuzikaGromche.Via; + +class ViaBehaviour : MonoBehaviour +{ + static ViaBehaviour? instance = null; + + public static ViaBehaviour Instance + { + get + { + if (instance == null) + { + var go = new GameObject("MuzikaGromche_ViaBehaviour", [ + typeof(ViaBehaviour), + ]) + { + hideFlags = HideFlags.HideAndDontSave + }; + DontDestroyOnLoad(go); + instance = go.GetComponent(); + } + return instance; + } + } + + DevicePool devicePool = null!; + DevicePoolLightshow lightshow = null!; + + void Awake() + { + devicePool = new DevicePool(new FrameworkDeviceFactory()); + lightshow = new DevicePoolLightshow(devicePool); + } + + void OnDestroy() + { + devicePool.Dispose(); + } + + public void SetColor(Color color) + { + Color.RGBToHSV(color, out var hue, out var saturation, out var value); + + var h = (byte)Mathf.RoundToInt(hue * 255); + var s = (byte)Mathf.RoundToInt(saturation * 255); + var v = (byte)Mathf.RoundToInt(value * 255); + + lightshow.SetColor(h, s, v); + } + + public void Restore() + { + devicePool.Restore(); + } + + public void Flicker() + { + Animations.Flicker(lightshow); + } +} + +[HarmonyPatch(typeof(RoundManager))] +static class ViaFlickerLightsPatch +{ + [HarmonyPatch(nameof(RoundManager.FlickerLights))] + [HarmonyPrefix] + static void OnFlickerLights(RoundManager __instance) + { + var _ = __instance; + ViaBehaviour.Instance.Flicker(); + } +} diff --git a/MuzikaGromche/Via/Via.cs b/MuzikaGromche/Via/Via.cs new file mode 100644 index 0000000..6d8c479 --- /dev/null +++ b/MuzikaGromche/Via/Via.cs @@ -0,0 +1,374 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using HidSharp; + +namespace MuzikaGromche.Via; + +enum ViaCommandId : byte +{ + GetProtocolVersion = 0x01, + GetKeyboardValue = 0x02, + SetKeyboardValue = 0x03, + DynamicKeymapGetKeycode = 0x04, + DynamicKeymapSetKeycode = 0x05, + DynamicKeymapReset = 0x06, + CustomSetValue = 0x07, + CustomGetValue = 0x08, + CustomSave = 0x09, + EepromReset = 0x0A, + BootloaderJump = 0x0B, + DynamicKeymapMacroGetCount = 0x0C, + DynamicKeymapMacroGetBufferSize = 0x0D, + DynamicKeymapMacroGetBuffer = 0x0E, + DynamicKeymapMacroSetBuffer = 0x0F, + DynamicKeymapMacroReset = 0x10, + DynamicKeymapGetLayerCount = 0x11, + DynamicKeymapGetBuffer = 0x12, + DynamicKeymapSetBuffer = 0x13, + DynamicKeymapGetEncoder = 0x14, + DynamicKeymapSetEncoder = 0x15, + Unhandled = 0xFF, +} + +enum ViaChannelId : byte +{ + CustomChannel = 0, + QmkBacklightChannel = 1, + QmkRgblightChannel = 2, + QmkRgbMatrixChannel = 3, + QmkAudioChannel = 4, + QmkLedMatrixChannel = 5, +} + +enum ViaQmkRgbMatrixValue : byte +{ + Brightness = 1, + Effect = 2, + EffectSpeed = 3, + Color = 4, +}; + +enum ViaEffectMode : byte +{ + Off = 0x00, + Static = 0x01, + Breathing = 0x02, +} + +class ViaKeyboardApi +{ + private const uint RAW_EPSIZE = 32; + private const byte COMMAND_START = 0x00; + + private readonly HidStream stream; + + public ViaKeyboardApi(HidStream stream) + { + this.stream = stream; + } + + /// + /// Sends a raw HID command prefixed with the command byte and returns the response if successful. + /// + public byte[]? HidCommand(ViaCommandId command, byte[] input) + { + byte[] commandBytes = [(byte)command, .. input]; + + if (!HidSend(commandBytes)) + { + // Console.WriteLine($"no send"); + return null; + } + + var buffer = HidRead(); + if (buffer == null) + { + // Console.WriteLine($"no read"); + return null; + } + + // Console.WriteLine($"write command {commandBytes.BytesToHex()}"); + // Console.WriteLine($"read buffer {buffer.BytesToHex()}"); + if (!buffer.AsSpan(1).StartsWith(commandBytes)) + { + return null; + } + return buffer.AsSpan(1).ToArray(); + } + + /// + /// Sends a raw HID command prefixed with the command byte and returns the response if successful. + /// + public async ValueTask HidCommandAsync(ViaCommandId command, byte[] input, CancellationToken cancellationToken) + { + byte[] commandBytes = [(byte)command, .. input]; + + if (!await HidSendAsync(commandBytes, cancellationToken)) + { + return null; + // Console.WriteLine($"no send"); + } + + var buffer = await HidReadAsync(cancellationToken); + if (buffer == null) + { + // Console.WriteLine($"no read"); + return null; + } + + // Console.WriteLine($"write command {commandBytes.BytesToHex()}"); + // Console.WriteLine($"read buffer {buffer.BytesToHex()}"); + if (!buffer.AsSpan(1).StartsWith(commandBytes)) + { + return null; + } + return buffer.AsSpan(1).ToArray(); + } + + /// + /// Reads from the HID device. Returns null if the read fails. + /// + public byte[]? HidRead() + { + byte[] buffer = new byte[RAW_EPSIZE + 1]; + // Console.WriteLine($"{buffer.BytesToHex()}"); + int count = stream.Read(buffer); + if (count != RAW_EPSIZE + 1) + { + return null; + } + return buffer; + } + + /// + /// Reads from the HID device. Returns null if the read fails. + /// + public async ValueTask HidReadAsync(CancellationToken cancellationToken) + { + byte[] buffer = new byte[RAW_EPSIZE + 1]; + // Console.WriteLine($"{buffer.BytesToHex()}"); + int count = await stream.ReadAsync(buffer, cancellationToken); + if (count != RAW_EPSIZE + 1) + { + return null; + } + return buffer; + } + + /// + /// Sends a raw HID command prefixed with the command byte. Returns false if the send fails. + /// + public bool HidSend(byte[] bytes) + { + if (bytes.Length > RAW_EPSIZE) + { + return false; + } + byte[] commandBytes = [COMMAND_START, .. bytes]; + byte[] paddedArray = new byte[RAW_EPSIZE + 1]; + commandBytes.AsSpan().CopyTo(paddedArray); + // Console.WriteLine($"Send {paddedArray.BytesToHex()}"); + stream.Write(paddedArray); + return true; + } + + /// + /// Sends a raw HID command prefixed with the command byte. Returns false if the send fails. + /// + public async ValueTask HidSendAsync(byte[] bytes, CancellationToken cancellationToken) + { + if (bytes.Length > RAW_EPSIZE) + { + return false; + } + byte[] commandBytes = [COMMAND_START, .. bytes]; + byte[] paddedArray = new byte[RAW_EPSIZE + 1]; + commandBytes.AsSpan().CopyTo(paddedArray); + // Console.WriteLine($"Send {paddedArray.BytesToHex()}"); + await stream.WriteAsync(paddedArray, cancellationToken); + return true; + } + + /// + /// Returns the protocol version of the keyboard. + /// + public ushort GetProtocolVersion() + { + var output = HidCommand(ViaCommandId.GetProtocolVersion, []); + if (output == null) + { + return 0; + } + return (ushort)((output[1] << 8) | output[2]); + } + + /// + /// Returns the protocol version of the keyboard. + /// + public async ValueTask GetProtocolVersionAsync(CancellationToken cancellationToken) + { + var output = await HidCommandAsync(ViaCommandId.GetProtocolVersion, [], cancellationToken); + if (output == null) + { + return 0; + } + return (ushort)((output[1] << 8) | output[2]); + } + + public byte? GetRgbMatrixBrightness() + { + var output = HidCommand(ViaCommandId.CustomGetValue, + [ + (byte)ViaChannelId.QmkRgbMatrixChannel, + (byte)ViaQmkRgbMatrixValue.Brightness, + ]); + if (output == null) + { + return null; + } + return output[3]; + } + + public async ValueTask GetRgbMatrixBrightnessAsync(CancellationToken cancellationToken) + { + var output = await HidCommandAsync(ViaCommandId.CustomGetValue, + [ + (byte)ViaChannelId.QmkRgbMatrixChannel, + (byte)ViaQmkRgbMatrixValue.Brightness, + ], cancellationToken); + if (output == null) + { + return null; + } + return output[3]; + } + + public bool SetRgbMatrixBrightness(byte brightness) + { + return HidCommand(ViaCommandId.CustomSetValue, + [ + (byte)ViaChannelId.QmkRgbMatrixChannel, + (byte)ViaQmkRgbMatrixValue.Brightness, + brightness, + ]) != null; + } + + public async ValueTask SetRgbMatrixBrightnessAsync(byte brightness, CancellationToken cancellationToken) + { + return await HidCommandAsync(ViaCommandId.CustomSetValue, + [ + (byte)ViaChannelId.QmkRgbMatrixChannel, + (byte)ViaQmkRgbMatrixValue.Brightness, + brightness, + ], cancellationToken) != null; + } + + public byte? GetRgbMatrixEffect() + { + var output = HidCommand(ViaCommandId.CustomGetValue, + [ + (byte)ViaChannelId.QmkRgbMatrixChannel, + (byte)ViaQmkRgbMatrixValue.Effect, + ]); + if (output == null) + { + return null; + } + return output[3]; + } + + public async ValueTask GetRgbMatrixEffectAsync(CancellationToken cancellationToken) + { + var output = await HidCommandAsync(ViaCommandId.CustomGetValue, + [ + (byte)ViaChannelId.QmkRgbMatrixChannel, + (byte)ViaQmkRgbMatrixValue.Effect, + ], cancellationToken); + if (output == null) + { + return null; + } + return output[3]; + } + + public bool SetRgbMatrixEffect(ViaEffectMode effect) + { + return SetRgbMatrixEffect((byte)effect); + } + + public bool SetRgbMatrixEffect(byte effect) + { + return HidCommand(ViaCommandId.CustomSetValue, [ + (byte)ViaChannelId.QmkRgbMatrixChannel, + (byte)ViaQmkRgbMatrixValue.Effect, + effect, + ]) != null; + } + + public ValueTask SetRgbMatrixEffectAsync(ViaEffectMode effect, CancellationToken cancellationToken) + { + return SetRgbMatrixEffectAsync((byte)effect, cancellationToken); + } + + public async ValueTask SetRgbMatrixEffectAsync(byte effect, CancellationToken cancellationToken) + { + return await HidCommandAsync(ViaCommandId.CustomSetValue, [ + (byte)ViaChannelId.QmkRgbMatrixChannel, + (byte)ViaQmkRgbMatrixValue.Effect, + effect, + ], cancellationToken) != null; + } + + public (byte Hue, byte Saturation)? GetRgbMatrixColor() + { + var output = HidCommand(ViaCommandId.CustomGetValue, + [ + (byte)ViaChannelId.QmkRgbMatrixChannel, + (byte)ViaQmkRgbMatrixValue.Color, + ]); + if (output == null) + { + return null; + } + byte hue = output[3]; + byte saturation = output[4]; + return (hue, saturation); + } + + public async ValueTask<(byte Hue, byte Saturation)?> GetRgbMatrixColorAsync(CancellationToken cancellationToken) + { + var output = await HidCommandAsync(ViaCommandId.CustomGetValue, + [ + (byte)ViaChannelId.QmkRgbMatrixChannel, + (byte)ViaQmkRgbMatrixValue.Color, + ], cancellationToken); + if (output == null) + { + return null; + } + byte hue = output[3]; + byte saturation = output[4]; + return (hue, saturation); + } + + public bool SetRgbMatrixColor(byte hue, byte saturation) + { + return HidCommand(ViaCommandId.CustomSetValue, [ + (byte)ViaChannelId.QmkRgbMatrixChannel, + (byte)ViaQmkRgbMatrixValue.Color, + hue, saturation, + ]) != null; + } + + public async ValueTask SetRgbMatrixColorAsync(byte hue, byte saturation, CancellationToken cancellationToken) + { + return await HidCommandAsync(ViaCommandId.CustomSetValue, [ + (byte)ViaChannelId.QmkRgbMatrixChannel, + (byte)ViaQmkRgbMatrixValue.Color, + hue, saturation, + ], cancellationToken) != null; + } +} + +public record struct HueSaturationColor(byte Hue, byte Saturation);