205 lines
7.5 KiB
Python
205 lines
7.5 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Experimental RGB control for the OneXPlayer Super X vendor HID interface.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
from pathlib import Path
|
|
|
|
# Observed OneXPlayer Super X RGB HID variants.
|
|
# 1a86:1305 exposes keyboard/touch-style interfaces plus a vendor-defined
|
|
# output interface at bInterfaceNumber 02. Older captures used 1a2c:b001:00.
|
|
DEFAULT_VENDOR_ID = 0x1A86
|
|
DEFAULT_PRODUCT_ID = 0x1305
|
|
DEFAULT_INTERFACE = 2
|
|
KNOWN_DEVICES = [
|
|
(0x1A86, 0x1305, 2),
|
|
(0x1A2C, 0xB001, 0),
|
|
]
|
|
PACKET_SIZE = 64
|
|
BRIGHTNESS_LEVELS = {
|
|
"off": [0x07, 0xFF, 0xFD, 0x00, 0x05, 0x01],
|
|
"1": [0x07, 0xFF, 0xFD, 0x01, 0x05, 0x01],
|
|
"2": [0x07, 0xFF, 0xFD, 0x01, 0x05, 0x03],
|
|
"3": [0x07, 0xFF, 0xFD, 0x01, 0x05, 0x04],
|
|
}
|
|
SAFE_PRESET_IDS = set(range(0x00, 0x17))
|
|
CAPTURED_PRESET_IDS = {0x0D, 0x03, 0x0B, 0x05, 0x07, 0x09, 0x0C, 0x02, 0x08}
|
|
|
|
|
|
def parse_int(value: str) -> int:
|
|
return int(value, 0)
|
|
|
|
|
|
def read_attr(path: Path, name: str) -> str | None:
|
|
attr = path / name
|
|
if not attr.exists():
|
|
return None
|
|
return attr.read_text(encoding="utf-8").strip()
|
|
|
|
|
|
def find_hidraw_path(vendor_id: int, product_id: int, interface: int) -> Path:
|
|
for hidraw in sorted(Path("/sys/class/hidraw").glob("hidraw*")):
|
|
device = (hidraw / "device").resolve()
|
|
usb_interface = device.parent
|
|
usb_device = usb_interface.parent
|
|
if read_attr(usb_device, "idVendor") != f"{vendor_id:04x}":
|
|
continue
|
|
if read_attr(usb_device, "idProduct") != f"{product_id:04x}":
|
|
continue
|
|
if read_attr(usb_interface, "bInterfaceNumber") != f"{interface:02x}":
|
|
continue
|
|
return Path("/dev") / hidraw.name
|
|
raise RuntimeError(
|
|
f"hidraw device not found for vendor=0x{vendor_id:04x} "
|
|
f"product=0x{product_id:04x} interface={interface}"
|
|
)
|
|
|
|
|
|
def find_known_hidraw_path() -> tuple[Path, int, int, int]:
|
|
for vendor_id, product_id, interface in KNOWN_DEVICES:
|
|
try:
|
|
return find_hidraw_path(vendor_id, product_id, interface), vendor_id, product_id, interface
|
|
except RuntimeError:
|
|
continue
|
|
raise RuntimeError(
|
|
"known RGB hidraw device not found; tried "
|
|
+ ", ".join(
|
|
f"0x{vendor:04x}:0x{product:04x}:if{interface}"
|
|
for vendor, product, interface in KNOWN_DEVICES
|
|
)
|
|
)
|
|
|
|
|
|
def pad_packet(prefix: list[int]) -> list[int]:
|
|
if len(prefix) > PACKET_SIZE:
|
|
raise ValueError(f"packet too long: {len(prefix)}")
|
|
return prefix + [0] * (PACKET_SIZE - len(prefix))
|
|
|
|
|
|
def build_static_packet(red: int, green: int, blue: int) -> list[int]:
|
|
for value in (red, green, blue):
|
|
if value < 0 or value > 255:
|
|
raise ValueError("RGB values must be in range 0-255")
|
|
payload = [0x07, 0xFF, 0xFE]
|
|
payload.extend([red, green, blue] * 20)
|
|
payload.append(0x00)
|
|
return payload
|
|
|
|
|
|
def build_mode_packet(mode: str) -> list[int]:
|
|
if mode == "static":
|
|
return pad_packet([0x07, 0xFF, 0xFD, 0x00, 0x05, 0x04])
|
|
if mode == "enable":
|
|
return pad_packet([0x07, 0xFF, 0x05, 0x00])
|
|
if mode == "rainbow":
|
|
return pad_packet([0x07, 0xFF, 0xFD, 0x01, 0x05, 0x04])
|
|
if mode == "alt-static":
|
|
return pad_packet([0x07, 0xFF, 0xFD, 0x01, 0x05, 0x04])
|
|
raise ValueError(f"unsupported mode: {mode}")
|
|
|
|
|
|
def build_level_packet(level: str) -> list[int]:
|
|
try:
|
|
return pad_packet(BRIGHTNESS_LEVELS[level])
|
|
except KeyError as exc:
|
|
raise ValueError(f"unsupported level: {level}") from exc
|
|
|
|
|
|
def build_preset_packet(preset_id: int) -> list[int]:
|
|
if preset_id not in SAFE_PRESET_IDS:
|
|
raise ValueError(
|
|
f"unsupported preset id 0x{preset_id:02x}; "
|
|
f"safe range: 0x00..0x16; "
|
|
f"captured ids: {', '.join(f'0x{value:02x}' for value in sorted(CAPTURED_PRESET_IDS))}"
|
|
)
|
|
return pad_packet([0x07, 0xFF, preset_id, 0x00, 0x00])
|
|
|
|
|
|
def build_parser():
|
|
parser = argparse.ArgumentParser(description="Experimental OneXPlayer Super X RGB HID helper")
|
|
parser.add_argument("--vendor-id", type=parse_int, default=None)
|
|
parser.add_argument("--product-id", type=parse_int, default=None)
|
|
parser.add_argument("--interface", type=int, default=None)
|
|
parser.add_argument("--dry-run", action="store_true")
|
|
parser.add_argument("--json", action="store_true")
|
|
|
|
subparsers = parser.add_subparsers(dest="cmd", required=True)
|
|
|
|
static = subparsers.add_parser("static", help="Send a static RGB color packet")
|
|
static.add_argument("--dry-run", action="store_true")
|
|
static.add_argument("--json", action="store_true")
|
|
static.add_argument("red", type=int)
|
|
static.add_argument("green", type=int)
|
|
static.add_argument("blue", type=int)
|
|
|
|
mode = subparsers.add_parser("mode", help="Send a captured mode packet")
|
|
mode.add_argument("--dry-run", action="store_true")
|
|
mode.add_argument("--json", action="store_true")
|
|
mode.add_argument("mode", choices=["static", "enable", "rainbow", "alt-static"])
|
|
|
|
level = subparsers.add_parser("level", help="Send a captured brightness/on-off packet")
|
|
level.add_argument("--dry-run", action="store_true")
|
|
level.add_argument("--json", action="store_true")
|
|
level.add_argument("level", choices=["off", "1", "2", "3"])
|
|
|
|
preset = subparsers.add_parser("preset", help="Send a captured preset-id packet")
|
|
preset.add_argument("--dry-run", action="store_true")
|
|
preset.add_argument("--json", action="store_true")
|
|
preset.add_argument("preset_id", type=parse_int)
|
|
|
|
return parser
|
|
|
|
|
|
def main():
|
|
args = build_parser().parse_args()
|
|
if args.cmd == "static":
|
|
packet = build_static_packet(args.red, args.green, args.blue)
|
|
action = {"cmd": "static", "rgb": [args.red, args.green, args.blue]}
|
|
elif args.cmd == "mode":
|
|
packet = build_mode_packet(args.mode)
|
|
action = {"cmd": "mode", "mode": args.mode}
|
|
elif args.cmd == "level":
|
|
packet = build_level_packet(args.level)
|
|
action = {"cmd": "level", "level": args.level}
|
|
elif args.cmd == "preset":
|
|
packet = build_preset_packet(args.preset_id)
|
|
action = {"cmd": "preset", "preset_id": args.preset_id}
|
|
else:
|
|
raise RuntimeError(f"unsupported command: {args.cmd}")
|
|
|
|
result = {
|
|
**action,
|
|
"interface": args.interface if args.interface is not None else "auto",
|
|
"packet": packet,
|
|
}
|
|
if args.dry_run:
|
|
result["status"] = "dry-run"
|
|
print(json.dumps(result, indent=2) if args.json else result)
|
|
return
|
|
|
|
if args.vendor_id is None and args.product_id is None and args.interface is None:
|
|
hidraw_path, args.vendor_id, args.product_id, args.interface = find_known_hidraw_path()
|
|
else:
|
|
vendor_id = DEFAULT_VENDOR_ID if args.vendor_id is None else args.vendor_id
|
|
product_id = DEFAULT_PRODUCT_ID if args.product_id is None else args.product_id
|
|
interface = DEFAULT_INTERFACE if args.interface is None else args.interface
|
|
hidraw_path = find_hidraw_path(vendor_id, product_id, interface)
|
|
args.vendor_id, args.product_id, args.interface = vendor_id, product_id, interface
|
|
result["vendor_id"] = f"0x{args.vendor_id:04x}"
|
|
result["product_id"] = f"0x{args.product_id:04x}"
|
|
result["interface"] = args.interface
|
|
with hidraw_path.open("r+b", buffering=0) as device:
|
|
written = device.write(bytes(packet))
|
|
result["status"] = "ok"
|
|
result["written"] = written
|
|
result["path"] = str(hidraw_path)
|
|
|
|
print(json.dumps(result, indent=2) if args.json else result)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|