Initial HyperX Eve 1800 SignalRGB plugin and protocol tools
Working 10-zone plugin using the 0x44 live colour reports on the 0xFF90 vendor interface (data then commit). Includes Python tools used to identify the device, confirm the protocol and map zones. Output is dimmer than onboard effects; investigation pending. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01EcbUcbNpyPn7cGpe4t6y5y
This commit is contained in:
commit
5f36610a60
11 files changed
+491
No files matched your search
@@ -0,0 +1,2 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
export function Name() { return "HyperX Eve 1800"; }
|
||||||
|
export function VendorId() { return 0x03F0; }
|
||||||
|
export function ProductId() { return 0x08C2; }
|
||||||
|
export function Publisher() { return "Steve Ferguson"; }
|
||||||
|
export function Size() { return [21, 6]; }
|
||||||
|
export function DefaultPosition() { return [10, 100]; }
|
||||||
|
export function DefaultScale() { return 8.0; }
|
||||||
|
export function DeviceType() { return "keyboard"; }
|
||||||
|
export function ConflictingProcesses() { return ["NGenuity2.exe"]; }
|
||||||
|
/* global
|
||||||
|
shutdownColor:readonly
|
||||||
|
LightingMode:readonly
|
||||||
|
forcedColor: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"},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Eve 1800 has 10 lighting zones, each a vertical strip of the board,
|
||||||
|
// ordered left to right. X positions follow the physical key columns
|
||||||
|
// (roughly one unit per key) so effects sweep across the board evenly.
|
||||||
|
const vLedNames = [
|
||||||
|
"Zone 1 (Esc, F1-F4, Tab-Ctrl)",
|
||||||
|
"Zone 2 (2-4, E R, S D X)",
|
||||||
|
"Zone 3 (F5-F8, 5 6, T F G C V)",
|
||||||
|
"Zone 4 (7, Y H, B N, Space)",
|
||||||
|
"Zone 5 (8 9, U I J K M)",
|
||||||
|
"Zone 6 (0, O P L ;, , . /, Alt)",
|
||||||
|
"Zone 7 (- = Backspace, [ ] \\, Enter, Shift, Fn)",
|
||||||
|
"Zone 8 (F9-F12, Nav, Arrows)",
|
||||||
|
"Zone 9 (Numpad Left)",
|
||||||
|
"Zone 10 (Numpad Right)",
|
||||||
|
];
|
||||||
|
|
||||||
|
const vLedPositions = [
|
||||||
|
[1, 3], [3, 3], [5, 3], [7, 3], [9, 3],
|
||||||
|
[11, 3], [13, 3], [15, 3], [17, 3], [19, 3],
|
||||||
|
];
|
||||||
|
|
||||||
|
export function LedNames() { return vLedNames; }
|
||||||
|
export function LedPositions() { return vLedPositions; }
|
||||||
|
|
||||||
|
// Protocol (0xFF90 vendor interface, 64-byte reports, report ID 0x44):
|
||||||
|
// 44 02 <chunk> 00 + up to 20 x RGB colour data
|
||||||
|
// 44 01 <chunk count> apply ("commit") the frame
|
||||||
|
// The commit must come AFTER the data. These are live writes; nothing is
|
||||||
|
// saved to the keyboard. Without fresh frames the keyboard eventually falls
|
||||||
|
// back to its onboard effect, so we re-send periodically even when idle.
|
||||||
|
const KEEPALIVE_MS = 500;
|
||||||
|
let lastPacket = [];
|
||||||
|
let lastSendTime = 0;
|
||||||
|
|
||||||
|
export function Initialize() {
|
||||||
|
lastPacket = [];
|
||||||
|
lastSendTime = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Render() {
|
||||||
|
sendColors();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Shutdown(SystemSuspending) {
|
||||||
|
const color = SystemSuspending ? "#000000" : shutdownColor;
|
||||||
|
sendColors(color, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendColors(overrideColor, force = false) {
|
||||||
|
const packet = [0x44, 0x02, 0x00, 0x00];
|
||||||
|
|
||||||
|
for (let iIdx = 0; iIdx < vLedPositions.length; iIdx++) {
|
||||||
|
let color;
|
||||||
|
|
||||||
|
if (overrideColor) {
|
||||||
|
color = hexToRgb(overrideColor);
|
||||||
|
} else if (LightingMode === "Forced") {
|
||||||
|
color = hexToRgb(forcedColor);
|
||||||
|
} else {
|
||||||
|
color = device.color(vLedPositions[iIdx][0], vLedPositions[iIdx][1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
packet.push(color[0], color[1], color[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (!force && arraysEqual(packet, lastPacket) && now - lastSendTime < KEEPALIVE_MS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
device.write(packet, 64);
|
||||||
|
device.write([0x44, 0x01, 0x01], 64);
|
||||||
|
|
||||||
|
lastPacket = packet;
|
||||||
|
lastSendTime = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
function arraysEqual(a, b) {
|
||||||
|
if (a.length !== b.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
if (a[i] !== b[i]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToRgb(hex) {
|
||||||
|
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||||
|
const colors = [];
|
||||||
|
colors[0] = parseInt(result[1], 16);
|
||||||
|
colors[1] = parseInt(result[2], 16);
|
||||||
|
colors[2] = parseInt(result[3], 16);
|
||||||
|
|
||||||
|
return colors;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Validate(endpoint) {
|
||||||
|
return endpoint.interface === 2 && endpoint.usage === 0xFF00 && endpoint.usage_page === 0xFF90;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImageUrl() {
|
||||||
|
return "https://assets.signalrgb.com/devices/default/keyboards/full-size-keyboard-render.png";
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# HyperX Eve 1800 — SignalRGB plugin
|
||||||
|
|
||||||
|
Work-in-progress SignalRGB plugin for the HyperX Eve 1800 (USB `03F0:08C2`).
|
||||||
|
|
||||||
|
**Status:** working — all 10 zones respond to SignalRGB effects. Known issue: lighting is much dimmer than the keyboard's onboard effects (cause not yet found; see below).
|
||||||
|
|
||||||
|
## Install (manual)
|
||||||
|
Copy `HyperX_Eve_1800.js` to `Documents\WhirlwindFX\Plugins\` and restart SignalRGB.
|
||||||
|
|
||||||
|
## Protocol (as found so far)
|
||||||
|
Vendor HID interface 2, usage page `0xFF90`, 64-byte reports.
|
||||||
|
- `44 02 <chunk> 00` + 10 × RGB — colour data (zones left → right)
|
||||||
|
- `44 01 <chunk count>` — apply; must be sent **after** the data
|
||||||
|
- Live/volatile only — nothing is written to onboard memory.
|
||||||
|
|
||||||
|
## Open issues
|
||||||
|
- **Dim output.** Not SignalRGB's global brightness. Next step: run `tools/brightness_ab.py` (SignalRGB closed) to see if update rate affects brightness; check Fn+arrow brightness keys; else capture NGENUITY's brightness command with USBPcap.
|
||||||
|
|
||||||
|
## tools/
|
||||||
|
Python test scripts (`pip install hidapi`): `enumerate.py`, `descriptor.py`, `test_color.py`, `zone_probe.py`, `zone_walk.py` (results in `zone_map.json`), `brightness_ab.py`, shared helpers in `eve.py`.
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""A/B test: does sending frames faster make the Eve 1800 dimmer?
|
||||||
|
|
||||||
|
Run with SignalRGB fully closed. Each step shows full white for a few seconds
|
||||||
|
at a different update rate; press Enter to move on and type how bright it
|
||||||
|
looked (1 = barely lit, 5 = full brightness). Results go to brightness_ab.json.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import time
|
||||||
|
|
||||||
|
import eve
|
||||||
|
|
||||||
|
OUT = pathlib.Path(__file__).with_name("brightness_ab.json")
|
||||||
|
WHITE = [(255, 255, 255)] * 10
|
||||||
|
STEPS = [
|
||||||
|
("single frame, then nothing", 0),
|
||||||
|
("2 frames/sec (static-effect keep-alive)", 2),
|
||||||
|
("10 frames/sec", 10),
|
||||||
|
("33 frames/sec (SignalRGB animated effect)", 33),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def run_step(fps, seconds=6):
|
||||||
|
packets = eve.build_packets(WHITE)
|
||||||
|
dev = eve.open_device()
|
||||||
|
dev.set_nonblocking(True)
|
||||||
|
try:
|
||||||
|
end = time.monotonic() + seconds
|
||||||
|
while True:
|
||||||
|
for p in packets:
|
||||||
|
dev.write(p)
|
||||||
|
while dev.read(eve.REPORT_LEN):
|
||||||
|
pass
|
||||||
|
if not fps or time.monotonic() >= end:
|
||||||
|
break
|
||||||
|
time.sleep(1 / fps)
|
||||||
|
if not fps:
|
||||||
|
time.sleep(seconds)
|
||||||
|
finally:
|
||||||
|
dev.close()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
results = {}
|
||||||
|
for label, fps in STEPS:
|
||||||
|
input(f"\nNext: {label}. Watch the keyboard and press Enter...")
|
||||||
|
print(" showing white for 6 seconds...")
|
||||||
|
run_step(fps)
|
||||||
|
results[label] = input(" How bright was it, 1-5 (add notes if you like)? ").strip()
|
||||||
|
OUT.write_text(json.dumps(results, indent=2))
|
||||||
|
print(f"\nSaved to {OUT}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""Dump + decode the HID report descriptor of the vendor interface (read-only)."""
|
||||||
|
import hid
|
||||||
|
|
||||||
|
VID, PID, USAGE_PAGE = 0x03F0, 0x08C2, 0xFF90
|
||||||
|
|
||||||
|
info = next(d for d in hid.enumerate(VID, PID) if d["usage_page"] == USAGE_PAGE)
|
||||||
|
dev = hid.device()
|
||||||
|
dev.open_path(info["path"])
|
||||||
|
desc = bytes(dev.get_report_descriptor())
|
||||||
|
dev.close()
|
||||||
|
|
||||||
|
print(f"{len(desc)} bytes: {desc.hex(' ')}\n")
|
||||||
|
|
||||||
|
TAGS = {0x04: "UsagePage", 0x08: "Usage", 0xA0: "Collection", 0xC0: "EndCollection",
|
||||||
|
0x14: "LogicalMin", 0x24: "LogicalMax", 0x74: "ReportSize", 0x94: "ReportCount",
|
||||||
|
0x84: "ReportID", 0x80: "Input", 0x90: "Output", 0xB0: "Feature",
|
||||||
|
0x18: "UsageMin", 0x28: "UsageMax"}
|
||||||
|
i = 0
|
||||||
|
while i < len(desc):
|
||||||
|
prefix = desc[i]
|
||||||
|
size = {0: 0, 1: 1, 2: 2, 3: 4}[prefix & 3]
|
||||||
|
val = int.from_bytes(desc[i + 1:i + 1 + size], "little")
|
||||||
|
print(f" {TAGS.get(prefix & 0xFC, f'0x{prefix:02X}'):14} 0x{val:X}")
|
||||||
|
i += 1 + size
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
"""List every HID interface of the HyperX Eve 1800 (read-only; sends nothing)."""
|
||||||
|
import hid
|
||||||
|
|
||||||
|
VID, PID = 0x03F0, 0x08C2
|
||||||
|
|
||||||
|
for d in sorted(hid.enumerate(VID, PID), key=lambda d: (d["interface_number"], d["usage_page"], d["usage"])):
|
||||||
|
print(f"iface={d['interface_number']} usage_page=0x{d['usage_page']:04X} usage=0x{d['usage']:04X} "
|
||||||
|
f"product={d['product_string']!r} mfr={d['manufacturer_string']!r} "
|
||||||
|
f"serial={d['serial_number']!r} release=0x{d['release_number']:04X}")
|
||||||
|
print(f" path={d['path'].decode(errors='replace')}")
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""Shared helpers for talking to the HyperX Eve 1800 lighting interface.
|
||||||
|
|
||||||
|
Confirmed working (2026-10-25): write every colour-data packet
|
||||||
|
`44 02 <chunk> 00 + 20 x RGB`, THEN commit with `44 01 <chunk count>`.
|
||||||
|
No init packet needed. Only report 0x44 is used; undo by replugging.
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
|
||||||
|
import hid
|
||||||
|
|
||||||
|
VID, PID, USAGE_PAGE = 0x03F0, 0x08C2, 0xFF90
|
||||||
|
REPORT_LEN = 64 # 1 report-ID byte + 63 data bytes (from descriptor)
|
||||||
|
LEDS_PER_PACKET = 20 # 60 RGB bytes after the 4-byte header
|
||||||
|
|
||||||
|
|
||||||
|
def pad(pkt):
|
||||||
|
return bytes(pkt + [0] * (REPORT_LEN - len(pkt)))
|
||||||
|
|
||||||
|
|
||||||
|
def build_packets(colors, order="commit"):
|
||||||
|
"""colors: list of (r, g, b), one per LED slot."""
|
||||||
|
data = [c for rgb in colors for c in rgb]
|
||||||
|
chunks = [data[i:i + LEDS_PER_PACKET * 3] for i in range(0, len(data), LEDS_PER_PACKET * 3)]
|
||||||
|
frames = [pad([0x44, 0x02, idx, 0x00] + chunk) for idx, chunk in enumerate(chunks)]
|
||||||
|
header = pad([0x44, 0x01, len(frames)])
|
||||||
|
return frames + [header] if order == "commit" else [header] + frames
|
||||||
|
|
||||||
|
|
||||||
|
def open_device():
|
||||||
|
info = next((d for d in hid.enumerate(VID, PID) if d["usage_page"] == USAGE_PAGE), None)
|
||||||
|
if info is None:
|
||||||
|
raise SystemExit("HyperX Eve 1800 lighting interface not found - is it plugged in?")
|
||||||
|
dev = hid.device()
|
||||||
|
dev.open_path(info["path"])
|
||||||
|
return dev
|
||||||
|
|
||||||
|
|
||||||
|
def read_replies(dev, timeout_ms=150):
|
||||||
|
replies = []
|
||||||
|
deadline = time.monotonic() + timeout_ms / 1000
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
r = dev.read(REPORT_LEN, 20)
|
||||||
|
if r:
|
||||||
|
replies.append(bytes(r))
|
||||||
|
return replies
|
||||||
|
|
||||||
|
|
||||||
|
def send(colors, order="commit", verbose=True):
|
||||||
|
packets = build_packets(colors, order)
|
||||||
|
dev = open_device()
|
||||||
|
try:
|
||||||
|
for p in packets:
|
||||||
|
if verbose:
|
||||||
|
print("TX", p[:16].hex(" "), "...")
|
||||||
|
if dev.write(p) <= 0:
|
||||||
|
print("WRITE FAILED:", dev.error())
|
||||||
|
for r in read_replies(dev):
|
||||||
|
if verbose:
|
||||||
|
print("RX", r[:16].hex(" "), "...")
|
||||||
|
finally:
|
||||||
|
dev.close()
|
||||||
|
|
||||||
|
|
||||||
|
def stream(colors, seconds, fps=10, order="commit"):
|
||||||
|
"""Re-send the same frame repeatedly, like SignalRGB's Render() loop."""
|
||||||
|
packets = build_packets(colors, order)
|
||||||
|
dev = open_device()
|
||||||
|
dev.set_nonblocking(True)
|
||||||
|
frames = errors = 0
|
||||||
|
end = time.monotonic() + seconds
|
||||||
|
try:
|
||||||
|
while time.monotonic() < end:
|
||||||
|
for p in packets:
|
||||||
|
if dev.write(p) <= 0:
|
||||||
|
errors += 1
|
||||||
|
while dev.read(REPORT_LEN): # drain replies so the input queue never fills
|
||||||
|
pass
|
||||||
|
frames += 1
|
||||||
|
time.sleep(1 / fps)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
dev.close()
|
||||||
|
print(f"sent {frames} frames, {errors} write errors")
|
||||||
|
|
||||||
|
|
||||||
|
def hex_rgb(s):
|
||||||
|
b = bytes.fromhex(s.lstrip("#"))
|
||||||
|
return (b[0], b[1], b[2])
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""Set every LED slot of the HyperX Eve 1800 to one colour (report 0x44 only).
|
||||||
|
|
||||||
|
python tools/test_color.py # all red
|
||||||
|
python tools/test_color.py --color 000000 # all off
|
||||||
|
python tools/test_color.py --order primer # header-first order (does NOT work on the Eve)
|
||||||
|
python tools/test_color.py --dry-run # print packets, send nothing
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
import eve
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--color", default="ff0000", help="hex RRGGBB (default red)")
|
||||||
|
ap.add_argument("--leds", type=int, default=20, help="LED slots to fill (default 20)")
|
||||||
|
ap.add_argument("--order", choices=["commit", "primer"], default="commit")
|
||||||
|
ap.add_argument("--dry-run", action="store_true")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
colors = [eve.hex_rgb(args.color)] * args.leds
|
||||||
|
if args.dry_run:
|
||||||
|
for p in eve.build_packets(colors, args.order):
|
||||||
|
print("TX", p.hex(" "))
|
||||||
|
return
|
||||||
|
eve.send(colors, args.order)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"0": "esc f1-f4, `123 tab caps shift ctrl qwazx windows",
|
||||||
|
"1": "ersdx1234",
|
||||||
|
"2": "edcffvbgtre56r f5-f8",
|
||||||
|
"3": "7890yughjb spacen",
|
||||||
|
"4": "7890-=uiopjkl;;nm,",
|
||||||
|
"5": "opl;,./alt",
|
||||||
|
"6": "fn shift enter []\\- backspace\\enter shift",
|
||||||
|
"7": "all arrows 741num f10-12 del ptrsc pg up and n",
|
||||||
|
"8": "numpad",
|
||||||
|
"9": "numpad"
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Light chosen LED slots in distinct colours (everything else off) to map zones.
|
||||||
|
|
||||||
|
python tools/zone_probe.py 0 1 2 3 4 # slot 0 red, 1 green, 2 blue, 3 yellow, 4 magenta
|
||||||
|
python tools/zone_probe.py --range 10 19 # light slots 10..19 all white
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
import eve
|
||||||
|
|
||||||
|
PALETTE = [("red", (255, 0, 0)), ("green", (0, 255, 0)), ("blue", (0, 0, 255)),
|
||||||
|
("yellow", (255, 200, 0)), ("magenta", (255, 0, 255)), ("cyan", (0, 255, 255)),
|
||||||
|
("white", (255, 255, 255))]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("slots", nargs="*", type=int)
|
||||||
|
ap.add_argument("--range", nargs=2, type=int, metavar=("FIRST", "LAST"))
|
||||||
|
ap.add_argument("--total", type=int, default=20, help="slots to send (default 20)")
|
||||||
|
ap.add_argument("--hold", type=float, default=0, help="keep re-sending for N seconds")
|
||||||
|
ap.add_argument("--fps", type=int, default=10, help="re-send rate with --hold (default 10)")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
colors = [(0, 0, 0)] * args.total
|
||||||
|
if args.range:
|
||||||
|
for s in range(args.range[0], args.range[1] + 1):
|
||||||
|
colors[s] = (255, 255, 255)
|
||||||
|
print(f"slots {args.range[0]}..{args.range[1]}: white")
|
||||||
|
for slot, (name, rgb) in zip(args.slots, PALETTE):
|
||||||
|
colors[slot] = rgb
|
||||||
|
print(f"slot {slot}: {name}")
|
||||||
|
if args.hold:
|
||||||
|
print(f"re-sending every {1000 // args.fps} ms for {args.hold} s (Ctrl+C to stop)")
|
||||||
|
eve.stream(colors, args.hold, args.fps)
|
||||||
|
else:
|
||||||
|
eve.send(colors, verbose=False)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""Interactive zone mapper: lights one LED slot at a time in white (all others
|
||||||
|
off) and records which keys you see lit. Results go to tools/zone_map.json.
|
||||||
|
|
||||||
|
.venv\\Scripts\\python tools\\zone_walk.py # slots 0..19
|
||||||
|
.venv\\Scripts\\python tools\\zone_walk.py --start 5 # resume from slot 5
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
import eve
|
||||||
|
|
||||||
|
OUT = pathlib.Path(__file__).with_name("zone_map.json")
|
||||||
|
|
||||||
|
|
||||||
|
class Streamer(threading.Thread):
|
||||||
|
"""Keeps re-sending the current frame so the keyboard never times out."""
|
||||||
|
|
||||||
|
def __init__(self, total, fps=10):
|
||||||
|
super().__init__(daemon=True)
|
||||||
|
self.colors = [(0, 0, 0)] * total
|
||||||
|
self.fps = fps
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
self.running = True
|
||||||
|
|
||||||
|
def set(self, colors):
|
||||||
|
with self.lock:
|
||||||
|
self.colors = list(colors)
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
dev = eve.open_device()
|
||||||
|
dev.set_nonblocking(True)
|
||||||
|
try:
|
||||||
|
while self.running:
|
||||||
|
with self.lock:
|
||||||
|
packets = eve.build_packets(self.colors)
|
||||||
|
for p in packets:
|
||||||
|
dev.write(p)
|
||||||
|
while dev.read(eve.REPORT_LEN):
|
||||||
|
pass
|
||||||
|
time.sleep(1 / self.fps)
|
||||||
|
finally:
|
||||||
|
dev.close()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--start", type=int, default=0)
|
||||||
|
ap.add_argument("--total", type=int, default=20)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
results = json.loads(OUT.read_text()) if OUT.exists() else {}
|
||||||
|
streamer = Streamer(args.total)
|
||||||
|
streamer.start()
|
||||||
|
|
||||||
|
print("For each slot, type the keys that are lit (e.g. 'Esc, F1-F4, ~ 1 2 Tab Q')")
|
||||||
|
print("Just press Enter if NOTHING is lit. Type q to stop.\n")
|
||||||
|
try:
|
||||||
|
for slot in range(args.start, args.total):
|
||||||
|
colors = [(0, 0, 0)] * args.total
|
||||||
|
colors[slot] = (255, 255, 255)
|
||||||
|
streamer.set(colors)
|
||||||
|
answer = input(f"Slot {slot:2} lit white -> which keys? ").strip()
|
||||||
|
if answer.lower() == "q":
|
||||||
|
break
|
||||||
|
results[str(slot)] = answer or "(nothing)"
|
||||||
|
OUT.write_text(json.dumps(results, indent=2))
|
||||||
|
finally:
|
||||||
|
streamer.running = False
|
||||||
|
streamer.join(timeout=2)
|
||||||
|
|
||||||
|
print(f"\nSaved to {OUT}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in new issue
Block a user