Skip to content

Commit c0a1a74

Browse files
Add QMK Raw HID keyboard provider (Beta) (v4.1.40)
New custom RGB.NET extension for QMK firmware keyboards over Raw HID. Brand-agnostic — discovery filters on HID usage page 0xFF60 / usage 0x61 so it works on any QMK board (NovelKeys, KBDFans, Drop, GMMK, Glorious and others) without a hardcoded VID/PID allow-list. Architecture: - Two protocols handled on the same HID interface. Handshake decides which the device speaks: - VIA-only (universal): single-LED device, drives RGB matrix base hue/sat/val + effect mode. - OpenRGB-QMK (firmware-side plugin): full per-key control via direct mode, chunked SetLedRange writes with 1ms inter-packet pacing. - Per-key boards get a semantic LedId.Keyboard_* mapping by looking up the VIA keymap JSON for their VID/PID from www.caniusevia.com on first connect and merging against the firmware's GetLedInfo matrix coordinates. Cached on disk under %APPDATA%/Chromatics/QmkKeymaps. Falls back to LedId.Custom1..N with a synthetic grid when no keymap is fetchable (offline / unknown board) so the device still works via the Mapping tab drag-position UX. - Auto-adopt on first enable: discovery runs, every responding board is registered to SettingsModel.deviceQmkRawHidAdoptedDevices and picked up by the provider's adopted-set filter. Subsequent launches reuse the persisted list. Per-keyboard disable on the Mapping tab covers the "I don't want this one" case for v1 Beta. - Hot-plug parity with PlayStation provider: DeviceList.Local.Changed reconciles the open set on USB connect/disconnect events. Per- device disable gate parity with LIFX / Hue queues so the Mapping tab disable persistence works end-to-end. UX: - Settings → Device Providers gets a "QMK Keyboards (Beta)" toggle with brand-list tooltip. - First-run device selector adds a matching tile. - Locale strings added to en.json and translated into the six non-EN locales via translate.py. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e294d67 commit c0a1a74

28 files changed

Lines changed: 2139 additions & 16 deletions

CHANGELOG.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22

33
All notable changes to Chromatics are documented here.
44

5-
## 4.1.39
5+
## 4.1.40
66

7-
- Updated underlying UI dependencies (Avalonia 12.0.3, System.Drawing.Common 10.0.8) for stability and bug fixes. No functional changes.
7+
- **New:** QMK Raw HID keyboard support (Beta). Covers custom keyboards from NovelKeys, KBDFans, Drop, GMMK, Glorious and any other brand running QMK firmware with Raw HID enabled. Enable it from Settings → Device Providers, or pick it on the first-run device selector. Chromatics auto-detects compatible boards on USB and adopts them — no firmware flashing or extra software required. Per-key lighting is driven via the OpenRGB-QMK plugin when the firmware has it installed; otherwise Chromatics drives the firmware's built-in RGB matrix base colour and effect mode (the VIA fallback path). For per-key boards, Chromatics looks up the physical key layout from the via-keyboards database automatically on first connect so the Highlight / Keybind layers line up with the right keys out of the box.
8+
- Updated dependency libraries to latest version
89

910
## 4.1.38
1011

Chromatics/Chromatics.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<OutputType>WinExe</OutputType>
55
<TargetFramework>net10.0-windows7.0</TargetFramework>
66
<StartupObject>Chromatics.Program</StartupObject>
7-
<Version>4.1.39.0</Version>
7+
<Version>4.1.40.0</Version>
88
<Authors>Danielle Thompson</Authors>
99
<ApplicationManifest>app.manifest</ApplicationManifest>
1010
<Copyright>logicallysynced 2026</Copyright>

Chromatics/Core/RGBController.cs

Lines changed: 82 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -304,8 +304,70 @@ public static void Setup()
304304
Logger.WriteConsole(Enums.LoggerTypes.Error, $"[LifxDeviceProvider] LoadDeviceProvider Error: {ex.Message}");
305305
}
306306
}
307-
308-
307+
308+
if (appSettings.deviceQmkRawHidEnabled)
309+
{
310+
try
311+
{
312+
// QMK Raw HID provider — auto-adopts every QMK-compatible
313+
// keyboard discovered on the USB bus when the user has
314+
// an empty persisted adopted-set (first launch after
315+
// enabling). The adopted-set is the union of (a) the
316+
// boards the user has explicitly seen in the Mapping
317+
// tab and not removed via per-device disable, and (b)
318+
// any new boards that appear on subsequent launches
319+
// — the keymap fetch + handshake is cheap so refreshing
320+
// is fine. Persistence stays in
321+
// deviceQmkRawHidAdoptedDevices for the next launch's
322+
// hot-plug filter.
323+
Chromatics.Extensions.RGB.NET.Devices.QmkRawHid.QmkRawHidRGBDeviceProvider.Instance.AdoptedDevices.Clear();
324+
var adopted = appSettings.deviceQmkRawHidAdoptedDevices ?? new List<QmkRawHidAdoptedDevice>();
325+
if (adopted.Count == 0)
326+
{
327+
// Discover once and adopt everything that responds.
328+
// Subsequent launches will reuse the persisted list
329+
// unless the user explicitly clears it.
330+
var discovered = Chromatics.Extensions.RGB.NET.Devices.QmkRawHid.Protocol.QmkRawHidDiscovery.Discover();
331+
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
332+
foreach (var c in discovered)
333+
{
334+
string mfg = "";
335+
string prod = "";
336+
try { mfg = c.Hid.GetManufacturer() ?? ""; } catch { }
337+
try { prod = c.Hid.GetProductName() ?? ""; } catch { }
338+
var key = $"{c.Hid.VendorID:X4}:{c.Hid.ProductID:X4}:{mfg}:{prod}";
339+
if (!seen.Add(key)) continue;
340+
adopted.Add(new QmkRawHidAdoptedDevice
341+
{
342+
VendorId = c.Hid.VendorID,
343+
ProductId = c.Hid.ProductID,
344+
Manufacturer = mfg,
345+
Product = prod,
346+
LedCount = c.LedCount,
347+
Protocol = c.Protocol.ToString(),
348+
ViaKeymapKey = string.Empty,
349+
});
350+
}
351+
appSettings.deviceQmkRawHidAdoptedDevices = adopted;
352+
if (adopted.Count > 0) AppSettings.SaveSettings(appSettings);
353+
}
354+
355+
foreach (var d in adopted)
356+
{
357+
Chromatics.Extensions.RGB.NET.Devices.QmkRawHid.QmkRawHidRGBDeviceProvider.Instance.AdoptedDevices.Add(
358+
new Chromatics.Extensions.RGB.NET.Devices.QmkRawHid.QmkRawHidAdoptedDeviceFilter(
359+
d.VendorId, d.ProductId, d.Manufacturer, d.Product));
360+
}
361+
362+
LoadDeviceProvider(Chromatics.Extensions.RGB.NET.Devices.QmkRawHid.QmkRawHidRGBDeviceProvider.Instance);
363+
}
364+
catch (Exception ex)
365+
{
366+
Logger.WriteConsole(Enums.LoggerTypes.Error, $"[QmkRawHidDeviceProvider] LoadDeviceProvider Error: {ex.Message}");
367+
}
368+
}
369+
370+
309371
if (appSettings.rgbRefreshRate <= 0) appSettings.rgbRefreshRate = 0.05;
310372

311373
_timerUpdateTrigger = new TimerUpdateTrigger();
@@ -378,6 +440,16 @@ public static void RemoveDevice(IRGBDevice device)
378440
catch { /* best-effort */ }
379441
});
380442
}
443+
else if (device is Extensions.RGB.NET.Devices.QmkRawHid.QmkRawHidDevice qmkDev)
444+
{
445+
// QMK boards have no captured pre-Chromatics state to
446+
// restore — the firmware's built-in RGB matrix mode
447+
// resumes by itself as soon as Update() stops sending
448+
// frames. Gate the queue so any buffered frames in
449+
// flight don't slip through, then let the firmware
450+
// take over.
451+
qmkDev.SetPerDeviceDisabled(true);
452+
}
381453

382454
surface.Detach(device);
383455

@@ -429,6 +501,11 @@ public static void AddDevice(IRGBDevice device)
429501
{
430502
hueDev.SetPerDeviceDisabled(false);
431503
}
504+
else if (device is Extensions.RGB.NET.Devices.QmkRawHid.QmkRawHidDevice qmkDev)
505+
{
506+
qmkDev.ResetCache();
507+
qmkDev.SetPerDeviceDisabled(false);
508+
}
432509

433510
// Tagged effects (startup rainbow, title-screen starfield)
434511
// build their per-device ListLedGroup at the moment the tag
@@ -852,6 +929,8 @@ public static bool LoadDeviceProvider(IRGBDeviceProvider provider)
852929
lifxDev.SetPerDeviceDisabled(true);
853930
else if (device is Extensions.RGB.NET.Devices.Hue.HueDevice hueDev)
854931
hueDev.SetPerDeviceDisabled(true);
932+
else if (device is Extensions.RGB.NET.Devices.QmkRawHid.QmkRawHidDevice qmkDev)
933+
qmkDev.SetPerDeviceDisabled(true);
855934
if (_activeDevices.ContainsKey(device))
856935
_activeDevices[device] = false;
857936
else
@@ -1236,7 +1315,7 @@ public static void RegisterTaggedEffect(string tag, Guid deviceGuid, ListLedGrou
12361315

12371316
// Tear down ONLY the groups registered under `tag` — used when the
12381317
// user disables Startup Animation / Title Screen via the Effects
1239-
// tab and we need to stop the rainbow / starfield mid-cycle without
1318+
// tab and we need to stop the effects mid-cycle without
12401319
// killing other running effects on the surface.
12411320
//
12421321
// LEDs are painted BLACK and one surface render is forced before
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
using System;
2+
using System.Buffers.Binary;
3+
4+
namespace Chromatics.Extensions.RGB.NET.Devices.QmkRawHid.Protocol
5+
{
6+
// OpenRGB QMK plugin command set (master branch reference, see
7+
// https://gitlab.com/CalcProgrammer1/OpenRGB → Controllers/QMKOpenRGBController).
8+
// This sits ON TOP of the same Raw HID transport that VIA uses;
9+
// VIA's commands occupy id 0x01-0x09, OpenRGB-QMK's start at 0x20
10+
// so the two can coexist on one HID interface. Firmware support
11+
// requires the qmk_openrgb fork or patch — we detect at handshake
12+
// and gracefully fall back to ViaProtocol if absent.
13+
//
14+
// Wire format: every request is `cmd_byte | request_payload[31]`.
15+
// Replies echo cmd_byte in byte 0; mismatched echoes mean the
16+
// firmware doesn't speak this command.
17+
internal static class OpenRgbQmkProtocol
18+
{
19+
public const byte Cmd_GetProtocolVersion = 0x20;
20+
public const byte Cmd_GetQmkVersion = 0x21;
21+
public const byte Cmd_GetDeviceInfo = 0x22;
22+
public const byte Cmd_GetLedInfo = 0x23;
23+
public const byte Cmd_GetEnabledModes = 0x24;
24+
public const byte Cmd_GetLedMatrixSize = 0x25;
25+
public const byte Cmd_SetLedRange = 0x27;
26+
public const byte Cmd_SetSingleLed = 0x28;
27+
public const byte Cmd_SetMode = 0x29;
28+
public const byte Cmd_Save = 0x2A;
29+
30+
// Each Cmd_SetLedRange packet carries (start_idx u16 | count u8 |
31+
// RGB triplets). Header overhead = 1 byte cmd + 2 bytes start +
32+
// 1 byte count = 4 bytes, leaving 28 bytes / 3 = 9 LEDs per packet.
33+
// Caller chunks the strip and paces packets to stay within the
34+
// firmware's RX queue (typical RX queue is 4-8 deep at full speed,
35+
// hence the 1ms inter-packet pacing we use in the queue).
36+
public const int MaxLedsPerSetRange = 9;
37+
38+
// ── Frame builders ────────────────────────────────────────────
39+
40+
public static void BuildGetProtocolVersion(Span<byte> payload)
41+
{
42+
payload.Clear();
43+
payload[0] = Cmd_GetProtocolVersion;
44+
}
45+
46+
public static void BuildGetDeviceInfo(Span<byte> payload)
47+
{
48+
payload.Clear();
49+
payload[0] = Cmd_GetDeviceInfo;
50+
}
51+
52+
// Cmd_GetLedInfo takes (start_idx u16) and returns matrix
53+
// (column, row) + flags for up to N LEDs starting at start_idx.
54+
// The firmware decides N based on remaining payload room
55+
// (typically 9 LEDs * 3 bytes per record = 27 + 1 byte echo).
56+
public static void BuildGetLedInfo(Span<byte> payload, ushort startIndex)
57+
{
58+
payload.Clear();
59+
payload[0] = Cmd_GetLedInfo;
60+
BinaryPrimitives.WriteUInt16LittleEndian(payload.Slice(1, 2), startIndex);
61+
}
62+
63+
public static void BuildGetLedMatrixSize(Span<byte> payload)
64+
{
65+
payload.Clear();
66+
payload[0] = Cmd_GetLedMatrixSize;
67+
}
68+
69+
// Bulk set: pack RGB triplets starting at byte 4. Caller must
70+
// ensure rgbBytes.Length == count * 3 and count <= MaxLedsPerSetRange.
71+
public static void BuildSetLedRange(Span<byte> payload, ushort startIndex, byte count, ReadOnlySpan<byte> rgbBytes)
72+
{
73+
if (count > MaxLedsPerSetRange) throw new ArgumentOutOfRangeException(nameof(count));
74+
if (rgbBytes.Length != count * 3) throw new ArgumentException("rgbBytes length must equal count*3", nameof(rgbBytes));
75+
payload.Clear();
76+
payload[0] = Cmd_SetLedRange;
77+
BinaryPrimitives.WriteUInt16LittleEndian(payload.Slice(1, 2), startIndex);
78+
payload[3] = count;
79+
rgbBytes.CopyTo(payload.Slice(4, count * 3));
80+
}
81+
82+
public static void BuildSetSingleLed(Span<byte> payload, ushort index, byte r, byte g, byte b)
83+
{
84+
payload.Clear();
85+
payload[0] = Cmd_SetSingleLed;
86+
BinaryPrimitives.WriteUInt16LittleEndian(payload.Slice(1, 2), index);
87+
payload[3] = r;
88+
payload[4] = g;
89+
payload[5] = b;
90+
}
91+
92+
// Switch the firmware to direct host-driven mode (mode 0 in the
93+
// OpenRGB-QMK convention) or back to a built-in effect index.
94+
// Direct mode suspends the firmware's own RGB matrix animations
95+
// so our Set commands aren't fighting them every tick.
96+
public static void BuildSetMode(Span<byte> payload, byte modeIndex)
97+
{
98+
payload.Clear();
99+
payload[0] = Cmd_SetMode;
100+
payload[1] = modeIndex;
101+
}
102+
103+
// ── Reply parsers ─────────────────────────────────────────────
104+
105+
public static ushort TryParseProtocolVersion(ReadOnlySpan<byte> reply)
106+
{
107+
if (reply.Length < 3) return 0;
108+
if (reply[0] != Cmd_GetProtocolVersion) return 0;
109+
return BinaryPrimitives.ReadUInt16LittleEndian(reply.Slice(1, 2));
110+
}
111+
112+
// GetDeviceInfo reply layout:
113+
// byte 0 = Cmd_GetDeviceInfo (echo)
114+
// bytes 1-2 = total LED count (uint16 LE)
115+
// bytes 3-4 = vendor id (uint16 LE)
116+
// bytes 5-6 = product id (uint16 LE)
117+
// byte 7 = device type (informational)
118+
// bytes 8+ = null-terminated device name
119+
public static bool TryParseDeviceInfo(ReadOnlySpan<byte> reply, out ushort ledCount, out ushort vendorId, out ushort productId, out string deviceName)
120+
{
121+
ledCount = 0; vendorId = 0; productId = 0; deviceName = string.Empty;
122+
if (reply.Length < 8) return false;
123+
if (reply[0] != Cmd_GetDeviceInfo) return false;
124+
ledCount = BinaryPrimitives.ReadUInt16LittleEndian(reply.Slice(1, 2));
125+
vendorId = BinaryPrimitives.ReadUInt16LittleEndian(reply.Slice(3, 2));
126+
productId = BinaryPrimitives.ReadUInt16LittleEndian(reply.Slice(5, 2));
127+
128+
int nameEnd = 8;
129+
while (nameEnd < reply.Length && reply[nameEnd] != 0) nameEnd++;
130+
if (nameEnd > 8)
131+
deviceName = System.Text.Encoding.UTF8.GetString(reply.Slice(8, nameEnd - 8));
132+
return true;
133+
}
134+
135+
// GetLedInfo reply layout (variable LED records, 3 bytes each):
136+
// byte 0 = Cmd_GetLedInfo (echo)
137+
// byte 1 = record count for this packet
138+
// then records: column (u8) | row (u8) | flags (u8)
139+
// Caller iterates start_index for additional batches.
140+
public static bool TryParseLedInfoBatch(ReadOnlySpan<byte> reply, out int recordCount, out ReadOnlySpan<byte> records)
141+
{
142+
recordCount = 0; records = default;
143+
if (reply.Length < 2) return false;
144+
if (reply[0] != Cmd_GetLedInfo) return false;
145+
recordCount = reply[1];
146+
int recordBytes = recordCount * 3;
147+
if (reply.Length < 2 + recordBytes) return false;
148+
records = reply.Slice(2, recordBytes);
149+
return true;
150+
}
151+
152+
public static bool TryParseLedMatrixSize(ReadOnlySpan<byte> reply, out byte columns, out byte rows)
153+
{
154+
columns = 0; rows = 0;
155+
if (reply.Length < 3) return false;
156+
if (reply[0] != Cmd_GetLedMatrixSize) return false;
157+
columns = reply[1];
158+
rows = reply[2];
159+
return true;
160+
}
161+
162+
public readonly struct LedRecord
163+
{
164+
public readonly byte Column;
165+
public readonly byte Row;
166+
public readonly byte Flags;
167+
public LedRecord(byte column, byte row, byte flags) { Column = column; Row = row; Flags = flags; }
168+
}
169+
}
170+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
namespace Chromatics.Extensions.RGB.NET.Devices.QmkRawHid.Protocol
2+
{
3+
// QMK Raw HID transport constants. Reference:
4+
// https://docs.qmk.fm/#/feature_rawhid (firmware side) and
5+
// OpenRGB's QMKOpenRGBController + VIA's protocol/constants
6+
// for the host side.
7+
internal static class QmkRawHidConstants
8+
{
9+
// Vendor-defined HID usage page + usage that every QMK keyboard
10+
// exposes on its Raw HID interface. Discovery filters HidSharp's
11+
// enumeration on these two values rather than VID/PID so we work
12+
// across NovelKeys, KBDFans, Drop, GMMK, Glorious, etc. without
13+
// a hardcoded allow-list.
14+
public const ushort RawHidUsagePage = 0xFF60;
15+
public const ushort RawHidUsage = 0x61;
16+
17+
// QMK Raw HID transfers are 32-byte payloads. Windows HidSharp
18+
// expects an extra leading report-id byte, so the output buffer
19+
// is 33 bytes total (report id 0 + 32 data bytes). Reply reports
20+
// are the same shape minus the leading id on the HidSharp read
21+
// side (HidStream strips the id from inputs).
22+
public const int ReportPayloadBytes = 32;
23+
public const int OutputReportBytes = ReportPayloadBytes + 1;
24+
25+
// Default request/response wait. Most QMK Raw HID round-trips
26+
// complete in ~5-20ms over USB Full Speed; 250ms is generous
27+
// and matches OpenRGB's default for the same protocol.
28+
public const int ResponseTimeoutMs = 250;
29+
}
30+
}

0 commit comments

Comments
 (0)