The keyboard echoes each colour packet back; wait for that echo before sending the next one, like OpenRGB's EVision driver, instead of firing all packets back to back. Skip packets whose colours did not change and resend the whole frame every 500 ms so the keyboard stays under plugin control. Add a passive listener tool for the echoes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
178 lines
7.5 KiB
JavaScript
178 lines
7.5 KiB
JavaScript
// 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
|
|
keyTest:readonly
|
|
frameLimit: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 pauses in between; normally not needed", type:"boolean", default:false},
|
|
{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"},
|
|
{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"},
|
|
];
|
|
}
|
|
|
|
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; 126 slots = 378 bytes = exactly 7 packets of 54
|
|
const LED_SLOTS = 126;
|
|
|
|
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);
|
|
}
|
|
|
|
let lastFrameTime = 0;
|
|
|
|
export function Render() {
|
|
// 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;
|
|
sendColors();
|
|
}
|
|
|
|
export function Shutdown(SystemSuspending) {
|
|
lastFullRefresh = 0; // always send the whole frame
|
|
sendColors(SystemSuspending ? "#000000" : shutdownColor);
|
|
}
|
|
|
|
function sendColors(overrideColor) {
|
|
const RGBData = new Array(LED_SLOTS * 3).fill(0);
|
|
const [width, height] = Size();
|
|
const step = Math.floor(Date.now() / 500);
|
|
|
|
for (let iIdx = 0; iIdx < vLeds.length; iIdx++) {
|
|
const [iPxX, iPxY] = vLedPositions[iIdx];
|
|
let color;
|
|
|
|
if (overrideColor) {
|
|
color = hexToRgb(overrideColor);
|
|
} 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];
|
|
} 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);
|
|
}
|
|
|
|
// Last packet sent per offset, so unchanged packets can be skipped
|
|
let sentPackets = {};
|
|
let lastFullRefresh = 0;
|
|
|
|
function writeRGBPackage(RGBData) {
|
|
// 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;
|
|
|
|
// 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 = {};
|
|
}
|
|
|
|
for (let offset = 0; offset < RGBData.length; offset += bytesToSend) {
|
|
const data = RGBData.slice(offset, offset + bytesToSend);
|
|
|
|
while (data.length < bytesToSend) { data.push(0); }
|
|
|
|
const key = `${bytesToSend}:${data.join(",")}`;
|
|
|
|
if (sentPackets[offset] === key) {
|
|
continue;
|
|
}
|
|
|
|
sentPackets[offset] = key;
|
|
|
|
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);
|
|
|
|
// 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
|
|
device.read([0x04], 64, 10);
|
|
|
|
// device.pause() rounds up to the Windows timer tick (up to ~15 ms), so only pause when asked
|
|
if (lowFPS) { device.pause(3); }
|
|
}
|
|
}
|
|
|
|
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)];
|
|
}
|