2026-10-28 23:05:05 +01:00
// 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
2026-10-28 23:09:48 +01:00
keyTest:readonly
2026-10-28 23:14:21 +01:00
frameLimit:readonly
2026-10-28 23:05:05 +01:00
*/
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" },
2026-10-28 23:19:39 +01:00
{ property : "lowFPS" , group : "lighting" , label : "Low frame-rate mode" , description : "Sends smaller packets with pauses in between; normally not needed" , type : "boolean" , default : false },
2026-10-28 23:14:21 +01:00
{ property : "frameLimit" , group : "lighting" , label : "Frame rate limit" , description : "Maximum frames per second sent to the keyboard" , type : "combobox" , values : [ "Unlimited" , "30" , "15" , "10" ], default : "Unlimited" },
2026-10-28 23:09:48 +01:00
{ property : "keyTest" , group : "lighting" , label : "Key test" , description : "Sweeps a white line across the keys to check the LED map" , type : "combobox" , values : [ "Off" , "Column sweep" , "Row sweep" ], default : "Off" },
2026-10-28 23:05:05 +01:00
];
}
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 ],
];
2026-10-28 23:19:39 +01:00
// Highest LED index is 125; 126 slots = 378 bytes = exactly 7 packets of 54
const LED_SLOTS = 126 ;
2026-10-28 23:05:05 +01:00
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 );
}
2026-10-28 23:14:21 +01:00
let lastFrameTime = 0 ;
2026-10-28 23:05:05 +01:00
export function Render () {
2026-10-28 23:14:21 +01:00
// The keyboard falls back to its own effect after about a second without frames,
// so the lowest limit (10 fps) still keeps it under plugin control
const now = Date . now ();
if ( frameLimit !== "Unlimited" && now - lastFrameTime < 1000 / parseInt ( frameLimit )) {
return ;
}
lastFrameTime = now ;
2026-10-28 23:05:05 +01:00
sendColors ();
}
export function Shutdown ( SystemSuspending ) {
2026-10-28 23:24:57 +01:00
lastFullRefresh = 0 ; // always send the whole frame
2026-10-28 23:05:05 +01:00
sendColors ( SystemSuspending ? "#000000" : shutdownColor );
}
function sendColors ( overrideColor ) {
const RGBData = new Array ( LED_SLOTS * 3 ). fill ( 0 );
2026-10-28 23:09:48 +01:00
const [ width , height ] = Size ();
const step = Math . floor ( Date . now () / 500 );
2026-10-28 23:05:05 +01:00
for ( let iIdx = 0 ; iIdx < vLeds . length ; iIdx ++ ) {
const [ iPxX , iPxY ] = vLedPositions [ iIdx ];
let color ;
if ( overrideColor ) {
color = hexToRgb ( overrideColor );
2026-10-28 23:09:48 +01:00
} else if ( keyTest === "Column sweep" ) {
color = iPxX === step % width ? [ 255 , 255 , 255 ] : [ 0 , 0 , 0 ];
} else if ( keyTest === "Row sweep" ) {
color = iPxY === step % height ? [ 255 , 255 , 255 ] : [ 0 , 0 , 0 ];
2026-10-28 23:05:05 +01:00
} 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 );
}
2026-10-28 23:24:57 +01:00
// Last packet sent per offset, so unchanged packets can be skipped
let sentPackets = {};
let lastFullRefresh = 0 ;
2026-10-28 23:05:05 +01:00
function writeRGBPackage ( RGBData ) {
2026-10-28 23:19:39 +01:00
// Must be a multiple of 3: the keyboard shows each packet as it arrives, so an LED split
// across two packets briefly flashes a mix of its old and new colour
const bytesToSend = lowFPS ? 48 : 54 ;
2026-10-28 23:05:05 +01:00
2026-10-28 23:24:57 +01:00
// Resend everything twice a second so the keyboard never falls back to its own effect
const now = Date . now ();
const fullRefresh = now - lastFullRefresh >= 500 ;
if ( fullRefresh ) {
lastFullRefresh = now ;
sentPackets = {};
}
2026-10-28 23:05:05 +01:00
for ( let offset = 0 ; offset < RGBData . length ; offset += bytesToSend ) {
const data = RGBData . slice ( offset , offset + bytesToSend );
while ( data . length < bytesToSend ) { data . push ( 0 ); }
2026-10-28 23:24:57 +01:00
const key = ` ${ bytesToSend } : ${ data . join ( "," ) } ` ;
if ( sentPackets [ offset ] === key ) {
continue ;
}
sentPackets [ offset ] = key ;
2026-10-28 23:05:05 +01:00
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 );
2026-10-28 23:09:48 +01:00
2026-10-28 23:24:57 +01:00
// The keyboard echoes each packet back; wait for it (as OpenRGB does) so the next
// packet doesn't arrive while it's still processing this one
2026-10-28 23:27:56 +01:00
device . read ([ 0x04 ], 64 , 25 );
2026-10-28 23:24:57 +01:00
2026-10-28 23:09:48 +01:00
// device.pause() rounds up to the Windows timer tick (up to ~15 ms), so only pause when asked
if ( lowFPS ) { device . pause ( 3 ); }
2026-10-28 23:05:05 +01:00
}
}
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 )];
}