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:
steveandClaude Opus 5.5 committed 2026-10-25 16:45:27 -07:00
commit 5f36610a60
11 files changed
+491

No files matched your search

+55
View File
@@ -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()
+24
View File
@@ -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
+10
View File
@@ -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')}")
+89
View File
@@ -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])
+30
View File
@@ -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()
+12
View File
@@ -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"
}
+40
View File
@@ -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()
+78
View File
@@ -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()