Add Womier K87 SignalRGB plugin and LED mapping tools
Per-key RGB for the wired Womier K87 (EVision firmware, 320F:502A) using the EVision protocol, with the 87-key LED map found by hand. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
ba56c6f8de
4 files changed
+254
No files matched your search
@@ -0,0 +1,29 @@
|
||||
# Womier K87 SignalRGB plugin
|
||||
|
||||
SignalRGB plugin for the wired Womier K87 with EVision firmware (USB `320F:502A`).
|
||||
SignalRGB's built-in EVision plugin speaks the same protocol but does not list this
|
||||
product ID or have a K87 layout, so the keyboard is ignored without this plugin.
|
||||
|
||||
## Install
|
||||
|
||||
Copy `Womier_K87_EVision.js` to `%USERPROFILE%\Documents\WhirlwindFX\Plugins\` and restart SignalRGB.
|
||||
|
||||
## Protocol
|
||||
|
||||
- HID interface 1, usage page `0xFF1C`, usage `0x92` (collection 4), 64-byte output reports.
|
||||
- Colour frame: packets of `[0x04, sumLo, sumHi, 0x12, len(56), offLo, offHi, 0x00, ...56 RGB bytes]`,
|
||||
where the checksum is the 16-bit sum of bytes 3..63. 7 packets cover LED slots 0..127.
|
||||
- The keyboard falls back to its own effect when frames stop, so colours must be streamed.
|
||||
|
||||
## LED map
|
||||
|
||||
LED index = column * 8 + row (same scheme as SignalRGB's Redragon K557 layout, minus the numpad).
|
||||
Exceptions: Page Up 115, Page Down 123, bottom row Space 45, Right Alt 77, Fn 85, Menu 93,
|
||||
Right Ctrl 101, arrows 109 / 117 / 125.
|
||||
|
||||
## Tools
|
||||
|
||||
- `tools/hidcaps.ps1` lists the keyboard's HID collections with usage page/usage and report sizes.
|
||||
- `tools/evision_led.ps1` sends test frames straight to the keyboard, e.g.
|
||||
`evision_led.ps1 -Leds "0=ff0000,21=00ff00" -Seconds 60` lights LED 0 red and 21 green for a minute.
|
||||
Used to map the LEDs; quit SignalRGB first so the two don't fight over the device.
|
||||
@@ -0,0 +1,127 @@
|
||||
// Womier K87 (wired, EVision firmware, USB 320F:502A).
|
||||
// Same per-key protocol as SignalRGB's built-in EVision plugin; LED numbering mapped
|
||||
// by hand on 2026-10-26 (column * 8 + row, like the Redragon K557 without numpad).
|
||||
export function Name() { return "Womier K87"; }
|
||||
export function VendorId() { return 0x320F; }
|
||||
export function ProductId() { return 0x502A; }
|
||||
export function Publisher() { return "Custom (based on WhirlwindFx & Nuonuo EVision plugin)"; }
|
||||
export function Size() { return [17, 6]; }
|
||||
export function DefaultPosition() { return [10, 100]; }
|
||||
export function DefaultScale() { return 8.0; }
|
||||
export function DeviceType() { return "keyboard"; }
|
||||
export function Validate(endpoint) { return endpoint.interface === 1; }
|
||||
export function ImageUrl() { return "https://assets.signalrgb.com/devices/default/misc/usb-drive-render.png"; }
|
||||
/* global
|
||||
shutdownColor:readonly
|
||||
LightingMode:readonly
|
||||
forcedColor:readonly
|
||||
lowFPS:readonly
|
||||
*/
|
||||
export function ControllableParameters() {
|
||||
return [
|
||||
{property:"shutdownColor", group:"lighting", label:"Shutdown Color", description: "This color is applied to the device when the System, or SignalRGB is shutting down", min:"0", max:"360", type:"color", default:"#000000"},
|
||||
{property:"LightingMode", group:"lighting", label:"Lighting Mode", description: "Determines where the device's RGB comes from. Canvas will pull from the active Effect, while Forced will override it to a specific color", type:"combobox", values:["Canvas", "Forced"], default:"Canvas"},
|
||||
{property:"forcedColor", group:"lighting", label:"Forced Color", description: "The color used when 'Forced' Lighting Mode is enabled", min:"0", max:"360", type:"color", default:"#009bde"},
|
||||
{property:"lowFPS", group:"lighting", label:"Low frame-rate mode", description: "Sends smaller packets with longer pauses, can improve stability", type:"boolean", default:false},
|
||||
];
|
||||
}
|
||||
|
||||
const vLedNames = [
|
||||
"Esc", "F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12", "Print Screen", "Scroll Lock", "Pause Break",
|
||||
"`", "1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "-_", "=+", "Backspace", "Insert", "Home", "Page Up",
|
||||
"Tab", "Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P", "[", "]", "\\", "Del", "End", "Page Down",
|
||||
"CapsLock", "A", "S", "D", "F", "G", "H", "J", "K", "L", ";", "'", "Enter",
|
||||
"Left Shift", "Z", "X", "C", "V", "B", "N", "M", ",", ".", "/", "Right Shift", "Up Arrow",
|
||||
"Left Ctrl", "Left Win", "Left Alt", "Space", "Right Alt", "Fn", "Menu", "Right Ctrl", "Left Arrow", "Down Arrow", "Right Arrow",
|
||||
];
|
||||
|
||||
const vLeds = [
|
||||
0, 8, 16, 24, 32, 40, 48, 56, 64, 72, 80, 88, 96, 104, 112, 120,
|
||||
1, 9, 17, 25, 33, 41, 49, 57, 65, 73, 81, 89, 97, 105, 113, 121, 115,
|
||||
2, 10, 18, 26, 34, 42, 50, 58, 66, 74, 82, 90, 98, 106, 114, 122, 123,
|
||||
3, 11, 19, 27, 35, 43, 51, 59, 67, 75, 83, 91, 107,
|
||||
4, 20, 28, 36, 44, 52, 60, 68, 76, 84, 92, 108, 116,
|
||||
5, 13, 21, 45, 77, 85, 93, 101, 109, 117, 125,
|
||||
];
|
||||
|
||||
const vLedPositions = [
|
||||
[0, 0], [2, 0], [3, 0], [4, 0], [5, 0], [6, 0], [7, 0], [8, 0], [9, 0], [10, 0], [11, 0], [12, 0], [13, 0], [14, 0], [15, 0], [16, 0],
|
||||
[0, 1], [1, 1], [2, 1], [3, 1], [4, 1], [5, 1], [6, 1], [7, 1], [8, 1], [9, 1], [10, 1], [11, 1], [12, 1], [13, 1], [14, 1], [15, 1], [16, 1],
|
||||
[0, 2], [1, 2], [2, 2], [3, 2], [4, 2], [5, 2], [6, 2], [7, 2], [8, 2], [9, 2], [10, 2], [11, 2], [12, 2], [13, 2], [14, 2], [15, 2], [16, 2],
|
||||
[0, 3], [1, 3], [2, 3], [3, 3], [4, 3], [5, 3], [6, 3], [7, 3], [8, 3], [9, 3], [10, 3], [11, 3], [13, 3],
|
||||
[0, 4], [2, 4], [3, 4], [4, 4], [5, 4], [6, 4], [7, 4], [8, 4], [9, 4], [10, 4], [11, 4], [13, 4], [15, 4],
|
||||
[0, 5], [1, 5], [2, 5], [6, 5], [10, 5], [11, 5], [12, 5], [13, 5], [14, 5], [15, 5], [16, 5],
|
||||
];
|
||||
|
||||
// Highest LED index is 125; 128 slots = 384 bytes = 7 full packets of 56
|
||||
const LED_SLOTS = 128;
|
||||
|
||||
export function LedNames() { return vLedNames; }
|
||||
export function LedPositions() { return vLedPositions; }
|
||||
|
||||
export function Initialize() {
|
||||
device.set_endpoint(1, 0x0092, 0xFF1C, 0x0004);
|
||||
device.setName("Womier K87");
|
||||
device.setControllableLeds(vLedNames, vLedPositions);
|
||||
}
|
||||
|
||||
export function Render() {
|
||||
sendColors();
|
||||
}
|
||||
|
||||
export function Shutdown(SystemSuspending) {
|
||||
sendColors(SystemSuspending ? "#000000" : shutdownColor);
|
||||
}
|
||||
|
||||
function sendColors(overrideColor) {
|
||||
const RGBData = new Array(LED_SLOTS * 3).fill(0);
|
||||
|
||||
for (let iIdx = 0; iIdx < vLeds.length; iIdx++) {
|
||||
const [iPxX, iPxY] = vLedPositions[iIdx];
|
||||
let color;
|
||||
|
||||
if (overrideColor) {
|
||||
color = hexToRgb(overrideColor);
|
||||
} else if (LightingMode === "Forced") {
|
||||
color = hexToRgb(forcedColor);
|
||||
} else {
|
||||
color = device.color(iPxX, iPxY);
|
||||
}
|
||||
|
||||
RGBData[vLeds[iIdx] * 3] = color[0];
|
||||
RGBData[vLeds[iIdx] * 3 + 1] = color[1];
|
||||
RGBData[vLeds[iIdx] * 3 + 2] = color[2];
|
||||
}
|
||||
|
||||
writeRGBPackage(RGBData);
|
||||
}
|
||||
|
||||
function writeRGBPackage(RGBData) {
|
||||
const bytesToSend = lowFPS ? 48 : 56;
|
||||
const pauseDuration = lowFPS ? 3 : 1;
|
||||
|
||||
for (let offset = 0; offset < RGBData.length; offset += bytesToSend) {
|
||||
const data = RGBData.slice(offset, offset + bytesToSend);
|
||||
|
||||
while (data.length < bytesToSend) { data.push(0); }
|
||||
|
||||
const packet = [0x04, 0x00, 0x00, 0x12, bytesToSend, offset & 0xFF, (offset >> 8) & 0xFF, 0x00].concat(data);
|
||||
|
||||
// Checksum: 16-bit sum of bytes 3..end (same result as the EVision plugin's formula)
|
||||
let sum = 0;
|
||||
|
||||
for (let i = 3; i < packet.length; i++) { sum += packet[i]; }
|
||||
|
||||
packet[1] = sum & 0xFF;
|
||||
packet[2] = (sum >> 8) & 0xFF;
|
||||
|
||||
device.write(packet, 64);
|
||||
device.pause(pauseDuration);
|
||||
}
|
||||
}
|
||||
|
||||
function hexToRgb(hex) {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
|
||||
return [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16)];
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
# Sends an EVision per-key colour frame (same packets as SignalRGB's EVision plugin)
|
||||
# Usage: evision_led.ps1 -Leds "0=ff0000,21=00ff00" [-Fill 000000] [-Count 128] [-Seconds 0] [-SoftwareMode]
|
||||
param(
|
||||
[string]$Leds = '',
|
||||
[string]$Fill = '000000',
|
||||
[int]$Count = 128,
|
||||
[double]$Seconds = 0,
|
||||
[switch]$SoftwareMode,
|
||||
[string]$Match = 'VID_320F&PID_502A&MI_01&COL04'
|
||||
)
|
||||
Add-Type @"
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
public static class EV {
|
||||
[DllImport("kernel32.dll", SetLastError=true, CharSet=CharSet.Unicode)]
|
||||
public static extern IntPtr CreateFile(string n, uint a, uint s, IntPtr sa, uint c, uint f, IntPtr t);
|
||||
[DllImport("kernel32.dll", SetLastError=true)]
|
||||
public static extern bool WriteFile(IntPtr h, byte[] b, int n, out int w, IntPtr o);
|
||||
[DllImport("kernel32.dll")] public static extern bool CloseHandle(IntPtr h);
|
||||
}
|
||||
"@
|
||||
$dev = Get-PnpDevice -PresentOnly -Class HIDClass | Where-Object { $_.InstanceId -match [regex]::Escape($Match) } | Select-Object -First 1
|
||||
if (-not $dev) { throw "RGB endpoint $Match not found" }
|
||||
$path = '\\?\' + ($dev.InstanceId -replace '\\','#') + '#{4d1e55b2-f16f-11cf-88cb-001111000030}'
|
||||
$h = [EV]::CreateFile($path, [uint32]3221225472, 3, [IntPtr]::Zero, 3, 0, [IntPtr]::Zero)
|
||||
if ($h -eq [IntPtr]-1) { throw "open failed, error $([Runtime.InteropServices.Marshal]::GetLastWin32Error())" }
|
||||
|
||||
function Send([byte[]]$pkt) {
|
||||
$buf = New-Object byte[] 64; [Array]::Copy($pkt, $buf, [Math]::Min(64, $pkt.Length))
|
||||
$w = 0
|
||||
if (-not [EV]::WriteFile($h, $buf, 64, [ref]$w, [IntPtr]::Zero)) { throw "write failed, error $([Runtime.InteropServices.Marshal]::GetLastWin32Error())" }
|
||||
}
|
||||
function Rgb([string]$hex) { $hex = $hex.TrimStart('#'); ,([byte[]]@([Convert]::ToByte($hex.Substring(0,2),16), [Convert]::ToByte($hex.Substring(2,2),16), [Convert]::ToByte($hex.Substring(4,2),16))) }
|
||||
|
||||
# Build the colour buffer
|
||||
$data = New-Object byte[] ($Count * 3)
|
||||
$f = Rgb $Fill
|
||||
for ($i = 0; $i -lt $Count; $i++) { $data[$i*3] = $f[0]; $data[$i*3+1] = $f[1]; $data[$i*3+2] = $f[2] }
|
||||
foreach ($e in ($Leds -split ',' | Where-Object { $_ })) {
|
||||
$idx, $col = $e.Split('=')
|
||||
if ($idx -match '^(\d+)-(\d+)$') { $range = [int]$Matches[1]..[int]$Matches[2] } else { $range = @([int]$idx) }
|
||||
$c = Rgb $col
|
||||
foreach ($i in $range) { if ($i -lt $Count) { $data[$i*3] = $c[0]; $data[$i*3+1] = $c[1]; $data[$i*3+2] = $c[2] } }
|
||||
}
|
||||
|
||||
function SendFrame {
|
||||
$per = 56
|
||||
for ($off = 0; $off -lt $data.Length; $off += $per) {
|
||||
$chunk = New-Object byte[] $per
|
||||
[Array]::Copy($data, $off, $chunk, 0, [Math]::Min($per, $data.Length - $off))
|
||||
$pkt = [byte[]](@(0x04, 0, 0, 0x12, $per, ($off -band 0xFF), (($off -shr 8) -band 0xFF), 0) + $chunk)
|
||||
# checksum = 16-bit sum of bytes 3..63 (matches the plugin's formula)
|
||||
$sum = 0; for ($k = 3; $k -lt 64; $k++) { $sum += $pkt[$k] }
|
||||
$pkt[1] = $sum -band 0xFF; $pkt[2] = ($sum -shr 8) -band 0xFF
|
||||
Send $pkt
|
||||
Start-Sleep -Milliseconds 1
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if ($SoftwareMode) { Send ([byte[]]@(0x04, 0x8c, 0x00, 0x0b, 0x30, 0x50, 0x01)); Start-Sleep -Milliseconds 20 }
|
||||
$end = (Get-Date).AddSeconds($Seconds); $n = 0
|
||||
do { SendFrame; $n++; if ($Seconds -gt 0) { Start-Sleep -Milliseconds 100 } } while ((Get-Date) -lt $end)
|
||||
"sent $n frame(s), $([Math]::Ceiling($data.Length / 56)) packets each"
|
||||
} finally { [void][EV]::CloseHandle($h) }
|
||||
@@ -0,0 +1,33 @@
|
||||
param([string]$Match = 'vid_320f&pid_502a')
|
||||
Add-Type @"
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
public static class H {
|
||||
[StructLayout(LayoutKind.Sequential)] public struct CAPS {
|
||||
public ushort Usage, UsagePage, InLen, OutLen, FeatLen;
|
||||
[MarshalAs(UnmanagedType.ByValArray, SizeConst=17)] public ushort[] R;
|
||||
public ushort a,b,c,d,e,f,g,h,i,j;
|
||||
}
|
||||
[DllImport("kernel32.dll", SetLastError=true, CharSet=CharSet.Unicode)]
|
||||
public static extern IntPtr CreateFile(string n, uint a, uint s, IntPtr sa, uint c, uint f, IntPtr t);
|
||||
[DllImport("kernel32.dll")] public static extern bool CloseHandle(IntPtr h);
|
||||
[DllImport("hid.dll")] public static extern bool HidD_GetPreparsedData(IntPtr h, out IntPtr p);
|
||||
[DllImport("hid.dll")] public static extern bool HidD_FreePreparsedData(IntPtr p);
|
||||
[DllImport("hid.dll")] public static extern int HidP_GetCaps(IntPtr p, out CAPS c);
|
||||
[DllImport("hid.dll", CharSet=CharSet.Unicode)] public static extern bool HidD_GetProductString(IntPtr h, StringBuilder b, int l);
|
||||
[DllImport("hid.dll", CharSet=CharSet.Unicode)] public static extern bool HidD_GetManufacturerString(IntPtr h, StringBuilder b, int l);
|
||||
}
|
||||
"@
|
||||
$guid = '{4d1e55b2-f16f-11cf-88cb-001111000030}'
|
||||
Get-PnpDevice -PresentOnly -Class HIDClass,Keyboard | Where-Object { $_.InstanceId -match [regex]::Escape($Match) } | ForEach-Object {
|
||||
$path = '\\?\' + ($_.InstanceId -replace '\\','#') + '#' + $guid
|
||||
$h = [H]::CreateFile($path, 0, 3, [IntPtr]::Zero, 3, 0, [IntPtr]::Zero)
|
||||
if ($h -eq [IntPtr]-1) { "$($_.InstanceId): open failed"; return }
|
||||
$p = [IntPtr]::Zero; [void][H]::HidD_GetPreparsedData($h, [ref]$p)
|
||||
$c = New-Object H+CAPS; [void][H]::HidP_GetCaps($p, [ref]$c)
|
||||
$ps = New-Object Text.StringBuilder 256; [void][H]::HidD_GetProductString($h, $ps, 512)
|
||||
$ms = New-Object Text.StringBuilder 256; [void][H]::HidD_GetManufacturerString($h, $ms, 512)
|
||||
'{0,-50} page=0x{1:X4} usage=0x{2:X4} in={3} out={4} feat={5} prod="{6}" mfr="{7}"' -f $_.InstanceId.Split('\')[1], $c.UsagePage, $c.Usage, $c.InLen, $c.OutLen, $c.FeatLen, $ps, $ms
|
||||
[void][H]::HidD_FreePreparsedData($p); [void][H]::CloseHandle($h)
|
||||
}
|
||||
Reference in new issue
Block a user