Files
Womier-K87-signal-rgb-plugin/Womier_K87_EVision.js
T
mangavalkandClaude Opus 5.5 0b2a90ba94 Wait for packet echoes and send only changed packets
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>
2026-10-28 23:24:57 +01:00

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