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