From 3ea341a969f6bab56f41afeb3072162cecb4743e Mon Sep 17 00:00:00 2001 From: LeRatierBretonnier Date: Wed, 27 May 2026 07:09:37 +0200 Subject: [PATCH] Initial release --- .gitignore | 1 + README.md | 35 ++ ph18_hid_dynamic_probe.py | 357 +++++++++++++++++ ph18_hid_rgb.py | 787 ++++++++++++++++++++++++++++++++++++++ ph18_rgb_menu.py | 177 +++++++++ 5 files changed, 1357 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 ph18_hid_dynamic_probe.py create mode 100644 ph18_hid_rgb.py create mode 100644 ph18_rgb_menu.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..9468743 --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +# Acer Predator PH18-73 LED Control (HID) + +Scripts migrated from `acer-predator-turbo-and-rgb-keyboard-linux-module` to provide PH18-73 LED control through HID. + +## Files + +- `ph18_hid_rgb.py`: main control script (keyboard, keyboard-zones, keyboard-dynamic, rear/logo/bar). +- `ph18_rgb_menu.py`: interactive text menu wrapper. +- `ph18_hid_dynamic_probe.py`: packet probe utility for dynamic mode discovery. + +## Quick start + +```bash +python3 ph18_hid_rgb.py list +sudo python3 ph18_rgb_menu.py +``` + +Direct examples: + +```bash +sudo python3 ph18_hid_rgb.py keyboard --color 00c8ff +sudo python3 ph18_hid_rgb.py keyboard-zones --z1 ff0000 --z2 00ff00 --z3 0000ff --z4 ffffff +sudo python3 ph18_hid_rgb.py keyboard-dynamic --repeats 2 +sudo python3 ph18_hid_rgb.py zone all static --color ff5500 +sudo python3 ph18_hid_rgb.py zone all off +``` + +Dynamic probe examples: + +```bash +python3 ph18_hid_dynamic_probe.py profiles +python3 ph18_hid_dynamic_probe.py list +sudo python3 ph18_hid_dynamic_probe.py run --profile legacy-86-with-blackout --repeats 2 +sudo python3 ph18_hid_dynamic_probe.py matrix --with-blackout --with-prelude --opcodes 33,4f,5b,5d,b3 --levels 1,9 +``` diff --git a/ph18_hid_dynamic_probe.py b/ph18_hid_dynamic_probe.py new file mode 100644 index 0000000..90571d2 --- /dev/null +++ b/ph18_hid_dynamic_probe.py @@ -0,0 +1,357 @@ +#!/usr/bin/env python3 +"""Probe candidate HID packets for keyboard dynamic mode on PH18-class laptops.""" + +from __future__ import annotations + +import argparse +import dataclasses +import fcntl +import os +import time +from pathlib import Path + +FF02_HID_IDS = {"0003:000005AF:0000867B", "0003:000005AF:0000866A"} + +IOC_NRBITS = 8 +IOC_TYPEBITS = 8 +IOC_SIZEBITS = 14 +IOC_DIRBITS = 2 + +IOC_NRSHIFT = 0 +IOC_TYPESHIFT = IOC_NRSHIFT + IOC_NRBITS +IOC_SIZESHIFT = IOC_TYPESHIFT + IOC_TYPEBITS +IOC_DIRSHIFT = IOC_SIZESHIFT + IOC_SIZEBITS + +IOC_WRITE = 1 +IOC_READ = 2 + + +def _ioc(direction: int, type_: int, number: int, size: int) -> int: + return ( + (direction << IOC_DIRSHIFT) + | (type_ << IOC_TYPESHIFT) + | (number << IOC_NRSHIFT) + | (size << IOC_SIZESHIFT) + ) + + +def hidiocsfeature(length: int) -> int: + return _ioc(IOC_WRITE | IOC_READ, ord("H"), 0x06, length) + + +@dataclasses.dataclass(frozen=True) +class HidrawInfo: + node: str + hid_id: str | None + interface: int | None + product: str | None + hid_name: str | None + + @property + def label(self) -> str: + parts = [self.node] + if self.hid_id: + parts.append(self.hid_id) + if self.interface is not None: + parts.append(f"if={self.interface}") + if self.product: + parts.append(self.product) + elif self.hid_name: + parts.append(self.hid_name) + return " ".join(parts) + + +@dataclasses.dataclass(frozen=True) +class PacketStep: + kind: str # "feature" or "output" + payload: bytes + note: str + + +def checksum8(prefix: list[int]) -> int: + return (~(sum(prefix) & 0xFF)) & 0xFF + + +def dynamic_mode_packet(mode_opcode: int, level: int) -> bytes: + prefix = [0x08, 0x02, mode_opcode & 0xFF, 0x05, 0x32, 0x08, level & 0xFF] + return bytes(prefix + [checksum8(prefix)]) + + +def parse_hex_byte_csv(value: str) -> list[int]: + out: list[int] = [] + for item in value.split(","): + raw = item.strip().lower().replace("0x", "") + if not raw: + continue + out.append(int(raw, 16) & 0xFF) + if not out: + raise SystemExit("expected at least one hex byte") + return out + + +def parse_int_csv(value: str) -> list[int]: + out: list[int] = [] + for item in value.split(","): + raw = item.strip() + if not raw: + continue + out.append(int(raw, 10) & 0xFF) + if not out: + raise SystemExit("expected at least one integer") + return out + + +def collect_hidraw_info() -> list[HidrawInfo]: + devices: list[HidrawInfo] = [] + for class_path in sorted(Path("/sys/class/hidraw").glob("hidraw*")): + device_path = (class_path / "device").resolve() + interface: int | None = None + product: str | None = None + fields: dict[str, str] = {} + for parent in [device_path, *device_path.parents]: + uevent = parent / "uevent" + if uevent.exists(): + try: + for line in uevent.read_text(encoding="utf-8", errors="replace").splitlines(): + if "=" not in line: + continue + key, value = line.split("=", 1) + fields.setdefault(key, value) + except OSError: + pass + if interface is None and (parent / "bInterfaceNumber").exists(): + try: + interface = int((parent / "bInterfaceNumber").read_text().strip(), 16) + except (OSError, ValueError): + pass + if product is None and (parent / "product").exists(): + try: + product = (parent / "product").read_text(encoding="utf-8", errors="replace").strip() + except OSError: + pass + devices.append( + HidrawInfo( + node=f"/dev/{class_path.name}", + hid_id=fields.get("HID_ID"), + interface=interface, + product=product, + hid_name=fields.get("HID_NAME"), + ) + ) + return devices + + +def select_keyboard_nodes(devices: list[HidrawInfo], explicit: str | None) -> list[HidrawInfo]: + if explicit: + for info in devices: + if info.node == explicit: + return [info] + raise SystemExit(f"device not found: {explicit}") + nodes = [info for info in devices if info.hid_id in FF02_HID_IDS] + if not nodes: + raise SystemExit("no 05af keyboard hidraw nodes found") + return sorted(nodes, key=lambda info: (info.interface if info.interface is not None else 99, info.node)) + + +def format_hexdump(payload: bytes, *, indent: str = " ", width: int = 16) -> str: + if not payload: + return f"{indent}0000" + lines: list[str] = [] + hex_width = width * 3 - 1 + for offset in range(0, len(payload), width): + chunk = payload[offset:offset + width] + hex_part = " ".join(f"{byte:02x}" for byte in chunk).ljust(hex_width) + ascii_part = "".join(chr(byte) if 32 <= byte <= 126 else "." for byte in chunk) + lines.append(f"{indent}{offset:04x} {hex_part} |{ascii_part}|") + return "\n".join(lines) + + +def send_feature(node: str, payload: bytes) -> None: + buffer = bytearray(payload) + with open(node, "r+b", buffering=0) as handle: + fcntl.ioctl(handle.fileno(), hidiocsfeature(len(buffer)), buffer, True) + + +def send_output(node: str, payload: bytes) -> None: + with open(node, "r+b", buffering=0) as handle: + os.write(handle.fileno(), payload) + + +def prelude_steps() -> list[PacketStep]: + return [ + PacketStep("feature", bytes([0x00, 0xB1, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x4E]), "ff02 prelude b1"), + PacketStep("feature", bytes([0x00, 0x08, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF5]), "ff02 prelude clear"), + PacketStep("feature", bytes([0x00, 0x08, 0x02, 0x4F, 0x0A, 0x32, 0x00, 0x00, 0x6A]), "ff02 prelude 4f"), + PacketStep("feature", bytes([0x00, 0x14, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0xEA]), "ff02 prelude 14"), + PacketStep("feature", bytes([0x00, 0x13, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0xE4]), "ff02 prelude 13"), + ] + + +def profile_steps(name: str) -> list[PacketStep]: + profiles: dict[str, list[PacketStep]] = { + "legacy-86": [ + PacketStep("feature", bytes([0x86, 0x01]), "report86 dynamic raw"), + PacketStep("feature", bytes([0x00, 0x86, 0x01]), "report86 dynamic prefixed"), + ], + "legacy-86-with-blackout": [ + PacketStep("feature", bytes([0x86, 0x00]), "report86 blackout"), + PacketStep("feature", bytes([0x86, 0x01]), "report86 dynamic raw"), + PacketStep("feature", bytes([0x00, 0x86, 0x01]), "report86 dynamic prefixed"), + ], + "pcap-b3": [ + PacketStep("feature", bytes([0x00, 0x08, 0x02, 0xB3, 0x05, 0x32, 0x08, 0x01, 0x02]), "pcap b3 variant A"), + PacketStep("feature", bytes([0x00, 0x08, 0x02, 0xB3, 0x05, 0x32, 0x08, 0x09, 0xFA]), "pcap b3 variant B"), + ], + "pcap-b3-with-zero-frame": [ + *prelude_steps(), + PacketStep("output", bytes(64), "64-byte zero output frame"), + PacketStep("feature", bytes([0x00, 0x08, 0x02, 0x4F, 0x05, 0x32, 0x08, 0x01, 0x66]), "4f commit"), + PacketStep("feature", bytes([0x00, 0x08, 0x02, 0xB3, 0x05, 0x32, 0x08, 0x01, 0x02]), "pcap b3 variant A"), + PacketStep("feature", bytes([0x00, 0x08, 0x02, 0xB3, 0x05, 0x32, 0x08, 0x09, 0xFA]), "pcap b3 variant B"), + PacketStep("feature", bytes([0x86, 0x01]), "report86 dynamic raw"), + ], + } + if name == "all": + merged: list[PacketStep] = [] + for key in ("legacy-86", "legacy-86-with-blackout", "pcap-b3", "pcap-b3-with-zero-frame"): + merged.extend(profiles[key]) + return merged + if name not in profiles: + raise SystemExit(f"unknown profile '{name}'") + return profiles[name] + + +def matrix_steps(opcodes: list[int], levels: list[int], *, prelude: bool, blackout: bool) -> list[PacketStep]: + steps: list[PacketStep] = [] + if prelude: + steps.extend(prelude_steps()) + if blackout: + steps.append(PacketStep("feature", bytes([0x86, 0x00]), "report86 blackout")) + for opcode in opcodes: + for level in levels: + steps.append( + PacketStep( + "feature", + dynamic_mode_packet(opcode, level), + f"matrix mode=0x{opcode:02x} level={level}", + ) + ) + steps.append(PacketStep("feature", bytes([0x86, 0x01]), "report86 dynamic raw")) + steps.append(PacketStep("feature", bytes([0x00, 0x86, 0x01]), "report86 dynamic prefixed")) + return steps + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Probe candidate dynamic-mode HID packets") + sub = parser.add_subparsers(dest="command", required=True) + + sub.add_parser("list", help="list keyboard hidraw candidates") + sub.add_parser("profiles", help="list available probe profiles") + + run = sub.add_parser("run", help="run one probe profile") + run.add_argument("--profile", required=True, help="legacy-86 | legacy-86-with-blackout | pcap-b3 | pcap-b3-with-zero-frame | all") + run.add_argument("--device", help="single hidraw node override") + run.add_argument("--repeats", type=int, default=1, help="repeat full profile sequence") + run.add_argument("--delay", type=float, default=0.03, help="delay between packets") + run.add_argument("--dry-run", action="store_true", help="print packets without sending") + + matrix = sub.add_parser("matrix", help="run generated dynamic mode matrix") + matrix.add_argument("--device", help="single hidraw node override") + matrix.add_argument( + "--opcodes", + default="33,4f,5b,5d,b3", + help="hex mode opcodes csv (default: 33,4f,5b,5d,b3)", + ) + matrix.add_argument( + "--levels", + default="1,9", + help="decimal levels csv mapped to packet byte7 (default: 1,9)", + ) + matrix.add_argument("--with-prelude", action="store_true", help="prepend ff02 prelude sequence") + matrix.add_argument("--with-blackout", action="store_true", help="prepend report86 blackout (86 00)") + matrix.add_argument("--repeats", type=int, default=1, help="repeat full matrix sequence") + matrix.add_argument("--delay", type=float, default=0.05, help="delay between packets") + matrix.add_argument("--dry-run", action="store_true", help="print packets without sending") + + return parser.parse_args() + + +def main() -> int: + args = parse_args() + devices = collect_hidraw_info() + + if args.command == "list": + print("keyboard candidates:") + for info in select_keyboard_nodes(devices, None): + print(f" {info.label}") + return 0 + + if args.command == "profiles": + print("profiles:") + print(" legacy-86") + print(" legacy-86-with-blackout") + print(" pcap-b3") + print(" pcap-b3-with-zero-frame") + print(" all") + return 0 + + if args.command == "run": + nodes = select_keyboard_nodes(devices, args.device) + steps = profile_steps(args.profile) + for info in nodes: + print(f"\n== node: {info.label} ==") + for loop in range(max(1, args.repeats)): + print(f"-- sequence {loop + 1} --") + for step in steps: + print(f"{step.kind} {step.note}") + print(format_hexdump(step.payload)) + if not args.dry_run: + try: + if step.kind == "feature": + send_feature(info.node, step.payload) + elif step.kind == "output": + send_output(info.node, step.payload) + else: + raise SystemExit(f"unsupported step kind: {step.kind}") + except OSError as exc: + print(f"warning: {exc}") + if args.delay > 0: + time.sleep(args.delay) + return 0 + + if args.command == "matrix": + nodes = select_keyboard_nodes(devices, args.device) + opcodes = parse_hex_byte_csv(args.opcodes) + levels = parse_int_csv(args.levels) + steps = matrix_steps( + opcodes, + levels, + prelude=args.with_prelude, + blackout=args.with_blackout, + ) + for info in nodes: + print(f"\n== node: {info.label} ==") + for loop in range(max(1, args.repeats)): + print(f"-- sequence {loop + 1} --") + for step in steps: + print(f"{step.kind} {step.note}") + print(format_hexdump(step.payload)) + if not args.dry_run: + try: + if step.kind == "feature": + send_feature(info.node, step.payload) + elif step.kind == "output": + send_output(info.node, step.payload) + else: + raise SystemExit(f"unsupported step kind: {step.kind}") + except OSError as exc: + print(f"warning: {exc}") + if args.delay > 0: + time.sleep(args.delay) + return 0 + + raise SystemExit("invalid command") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ph18_hid_rgb.py b/ph18_hid_rgb.py new file mode 100644 index 0000000..ae932d8 --- /dev/null +++ b/ph18_hid_rgb.py @@ -0,0 +1,787 @@ +#!/usr/bin/env python3 +"""Experimental HID RGB control for PH18-class laptops. + +This script targets models where WMI RGB calls succeed but do not change LEDs. +It uses hidraw devices directly: + - Main keyboard baseline via 05af:867b ff02 path + - Rear/logo/bar zones via 0d62:a21a / 0d62:a01a short packets +""" + +from __future__ import annotations + +import argparse +import dataclasses +import fcntl +import os +import time +from pathlib import Path + + +FF02_HID_IDS = {"0003:000005AF:0000867B", "0003:000005AF:0000866A"} +DARFON_HID_IDS = {"0003:00000D62:0000A21A", "0003:00000D62:0000A01A", "0003:00000D62:0000BA51"} + +DEFAULT_REPORT_DELAY = 0.02 +DEFAULT_STATIC_BRIGHTNESS = 0x19 +KEYBOARD_INDEX_COUNT = 102 + +ZONE_INDEX_GROUPS: tuple[range, range, range, range] = ( + range(0, 26), + range(26, 52), + range(52, 77), + range(77, KEYBOARD_INDEX_COUNT), +) + +IOC_NRBITS = 8 +IOC_TYPEBITS = 8 +IOC_SIZEBITS = 14 +IOC_DIRBITS = 2 + +IOC_NRSHIFT = 0 +IOC_TYPESHIFT = IOC_NRSHIFT + IOC_NRBITS +IOC_SIZESHIFT = IOC_TYPESHIFT + IOC_TYPEBITS +IOC_DIRSHIFT = IOC_SIZESHIFT + IOC_SIZEBITS + +IOC_WRITE = 1 +IOC_READ = 2 + + +def _ioc(direction: int, type_: int, number: int, size: int) -> int: + return ( + (direction << IOC_DIRSHIFT) + | (type_ << IOC_TYPESHIFT) + | (number << IOC_NRSHIFT) + | (size << IOC_SIZESHIFT) + ) + + +def hidiocsfeature(length: int) -> int: + return _ioc(IOC_WRITE | IOC_READ, ord("H"), 0x06, length) + + +@dataclasses.dataclass(frozen=True) +class HidrawInfo: + node: str + hid_id: str | None + interface: int | None + product: str | None + hid_name: str | None + + @property + def label(self) -> str: + parts = [self.node] + if self.hid_id: + parts.append(self.hid_id) + if self.interface is not None: + parts.append(f"if={self.interface}") + if self.product: + parts.append(self.product) + elif self.hid_name: + parts.append(self.hid_name) + return " ".join(parts) + + @property + def lowered_name(self) -> str: + return " ".join(part for part in [self.product, self.hid_name] if part).lower() + + +def _read_first_existing(base: Path, names: list[str]) -> str | None: + for name in names: + candidate = base / name + if not candidate.exists(): + continue + try: + value = candidate.read_text(encoding="utf-8", errors="replace").strip() + except OSError: + continue + if value: + return value + return None + + +def collect_hidraw_info() -> list[HidrawInfo]: + devices: list[HidrawInfo] = [] + for class_path in sorted(Path("/sys/class/hidraw").glob("hidraw*")): + device_path = (class_path / "device").resolve() + hid_id: str | None = None + interface: int | None = None + product: str | None = None + hid_name: str | None = None + uevent_fields: dict[str, str] = {} + + for parent in [device_path, *device_path.parents]: + uevent = parent / "uevent" + if uevent.exists(): + try: + for line in uevent.read_text(encoding="utf-8", errors="replace").splitlines(): + if "=" not in line: + continue + key, value = line.split("=", 1) + uevent_fields.setdefault(key, value) + except OSError: + pass + if interface is None and (parent / "bInterfaceNumber").exists(): + try: + interface = int((parent / "bInterfaceNumber").read_text().strip(), 16) + except (OSError, ValueError): + pass + if product is None: + product = _read_first_existing(parent, ["product"]) + + hid_id = uevent_fields.get("HID_ID") + hid_name = uevent_fields.get("HID_NAME") + devices.append( + HidrawInfo( + node=f"/dev/{class_path.name}", + hid_id=hid_id, + interface=interface, + product=product, + hid_name=hid_name, + ) + ) + return devices + + +def checksum8(prefix: list[int]) -> int: + return (~(sum(prefix) & 0xFF)) & 0xFF + + +def build_feature(prefix: list[int]) -> bytes: + if len(prefix) != 7: + raise ValueError("feature prefix must be exactly 7 bytes") + return bytes(prefix + [checksum8(prefix)]) + + +def parse_rgb(raw: str) -> tuple[int, int, int]: + text = raw.strip().lower().replace("#", "") + if "," in text: + parts = [part.strip() for part in text.split(",")] + if len(parts) != 3: + raise ValueError("CSV RGB must be R,G,B") + values = tuple(int(part, 10) for part in parts) + if any(value < 0 or value > 255 for value in values): + raise ValueError("RGB components must be in range 0..255") + return values[0], values[1], values[2] + if len(text) != 6: + raise ValueError("RGB must be 6 hex chars, e.g. ff00aa") + return int(text[0:2], 16), int(text[2:4], 16), int(text[4:6], 16) + + +def format_hexdump(payload: bytes, *, indent: str = " ", width: int = 16) -> str: + if not payload: + return f"{indent}0000" + lines: list[str] = [] + hex_width = width * 3 - 1 + for offset in range(0, len(payload), width): + chunk = payload[offset:offset + width] + hex_part = " ".join(f"{byte:02x}" for byte in chunk).ljust(hex_width) + ascii_part = "".join(chr(byte) if 32 <= byte <= 126 else "." for byte in chunk) + lines.append(f"{indent}{offset:04x} {hex_part} |{ascii_part}|") + return "\n".join(lines) + + +def ioctl_feature(node: str, payload: bytes) -> None: + buffer = bytearray(payload) + with open(node, "r+b", buffering=0) as handle: + fcntl.ioctl(handle.fileno(), hidiocsfeature(len(buffer)), buffer, True) + + +def write_output(node: str, payload: bytes) -> None: + with open(node, "r+b", buffering=0) as handle: + os.write(handle.fileno(), payload) + + +def select_ff02_nodes(devices: list[HidrawInfo], explicit: str | None) -> list[HidrawInfo]: + if explicit: + for info in devices: + if info.node == explicit: + return [info] + raise SystemExit(f"ff02 device not found: {explicit}") + + candidates = [ + info + for info in devices + if info.hid_id in FF02_HID_IDS and info.interface is not None and info.interface >= 0 + ] + if not candidates: + raise SystemExit("no ff02 keyboard HID node found (expected 05af:867b)") + + descriptor_candidates: list[HidrawInfo] = [] + for info in candidates: + descriptor_path = Path("/sys/class/hidraw") / Path(info.node).name / "device" / "report_descriptor" + try: + descriptor = descriptor_path.read_bytes() + except OSError: + continue + if descriptor.startswith(b"\x06\x02\xff"): + descriptor_candidates.append(info) + # Keep descriptor-matched nodes first, but also include the other + # interfaces for the same keyboard HID ID. Some PH18 units appear to + # split the board across interfaces. + ordered = sorted( + candidates, + key=lambda info: ( + 0 if info in descriptor_candidates else 1, + info.interface if info.interface is not None else 99, + info.node, + ), + ) + return ordered + + +def select_darfon_nodes(devices: list[HidrawInfo]) -> dict[str, HidrawInfo]: + candidates = [info for info in devices if info.hid_id in DARFON_HID_IDS and info.interface == 0] + if not candidates: + raise SystemExit("no Darfon HID nodes found (expected 0d62:a21a / 0d62:a01a)") + + rear = next((dev for dev in candidates if "infinitering" in dev.lowered_name), None) + logo = next((dev for dev in candidates if "logo" in dev.lowered_name), None) + + if rear is None: + rear = candidates[0] + if logo is None: + logo = next((dev for dev in candidates if dev.node != rear.node), rear) + + return {"rear": rear, "logo": logo, "bar": logo} + + +def _descriptor_contains(descriptor: bytes, pattern: bytes) -> bool: + return any(descriptor[i:i + len(pattern)] == pattern for i in range(len(descriptor) - len(pattern) + 1)) + + +def select_vendor_keyboard_nodes(devices: list[HidrawInfo], explicit: str | None) -> list[HidrawInfo]: + if explicit: + for info in devices: + if info.node == explicit: + return [info] + raise SystemExit(f"vendor keyboard device not found: {explicit}") + + candidates = [info for info in devices if info.hid_id in FF02_HID_IDS] + if not candidates: + raise SystemExit("no keyboard HID candidates found for per-key zone writes") + + vendor_nodes: list[HidrawInfo] = [] + for info in candidates: + descriptor_path = Path("/sys/class/hidraw") / Path(info.node).name / "device" / "report_descriptor" + try: + descriptor = descriptor_path.read_bytes() + except OSError: + continue + has_report82 = _descriptor_contains(descriptor, b"\x85\x82") + has_report83 = _descriptor_contains(descriptor, b"\x85\x83") + if has_report82 and has_report83: + vendor_nodes.append(info) + + if vendor_nodes: + return sorted( + vendor_nodes, + key=lambda info: (info.interface if info.interface is not None else 99, info.node), + ) + + return sorted( + candidates, + key=lambda info: (info.interface if info.interface is not None else 99, info.node), + ) + + +def select_keyboard_family_nodes(devices: list[HidrawInfo], explicit: str | None) -> list[HidrawInfo]: + if explicit: + for info in devices: + if info.node == explicit: + return [info] + raise SystemExit(f"keyboard device not found: {explicit}") + nodes = [info for info in devices if info.hid_id in FF02_HID_IDS] + if not nodes: + raise SystemExit("no 05af keyboard HID nodes found") + return sorted(nodes, key=lambda info: (info.interface if info.interface is not None else 99, info.node)) + + +def build_report84_single_index( + index: int, + red: int, + green: int, + blue: int, + *, + mode_selector: int = 1, + brightness_level: int = 8, +) -> bytes: + payload = bytearray() + payload.extend([0x84, mode_selector & 0xFF, min(8, brightness_level) & 0xFF]) + index_le = index.to_bytes(2, byteorder="little", signed=False) + for _ in range(8): + payload.extend(index_le) + for _ in range(8): + payload.extend([red & 0xFF, green & 0xFF, blue & 0xFF, 0x00]) + return bytes(payload) + + +def paint_index_via_report84(node: str, index: int, rgb: tuple[int, int, int], *, dry_run: bool) -> None: + report84 = build_report84_single_index(index, rgb[0], rgb[1], rgb[2]) + commit86 = bytes([0x86, 0x01]) + if dry_run: + print(f"{node} report84 index={index} rgb={rgb[0]},{rgb[1]},{rgb[2]}") + return + ioctl_feature(node, report84) + ioctl_feature(node, commit86) + + +def run_keyboard_baseline( + node: str, + rgb: tuple[int, int, int], + *, + dry_run: bool, + report_delay: float, + passes: int, + banks: int, + include_magkeys: bool, +) -> None: + frame_word = bytes([0xFF, rgb[0], rgb[1], rgb[2]]) + frame = frame_word * 16 + + prelude = ( + bytes([0xB1, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x4E]), + bytes([0x08, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF5]), + bytes([0x08, 0x02, 0x4F, 0x0A, 0x32, 0x00, 0x00, 0x6A]), + bytes([0x14, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0xEA]), + bytes([0x13, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0xE4]), + bytes([0x08, 0x02, 0x4F, 0x05, 0x32, 0x08, 0x01, 0x66]), + ) + pre_a = bytes([0x88, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x77]) + pre_b = bytes([0x12, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0xE5]) + commit33 = bytes([0x08, 0x02, 0x33, 0x05, 0x32, 0x08, 0x01, 0x82]) + + print(f"keyboard node: {node}") + print(f"word: ff:{rgb[0]:02x}:{rgb[1]:02x}:{rgb[2]:02x}") + print(f"passes={passes}, banks={banks}") + + for packet in prelude: + prefixed = b"\x00" + packet + print("feature ff02 prelude") + print(format_hexdump(prefixed)) + if not dry_run: + ioctl_feature(node, prefixed) + if report_delay > 0: + time.sleep(report_delay) + + for _ in range(passes): + for packet in (pre_a, pre_b): + prefixed = b"\x00" + packet + print("feature ff02 sync") + print(format_hexdump(prefixed)) + if not dry_run: + ioctl_feature(node, prefixed) + if report_delay > 0: + time.sleep(report_delay) + for _ in range(banks): + print("output frame") + print(format_hexdump(frame)) + if not dry_run: + write_output(node, frame) + if report_delay > 0: + time.sleep(report_delay) + prefixed_commit = b"\x00" + commit33 + print("feature ff02 commit33") + print(format_hexdump(prefixed_commit)) + if not dry_run: + ioctl_feature(node, prefixed_commit) + if report_delay > 0: + time.sleep(report_delay) + + if include_magkeys: + run_magkey_overlay(node, rgb, dry_run=dry_run, report_delay=report_delay) + + +def build_magkey_payload(rgb: tuple[int, int, int]) -> bytes: + # Emitters: + # 0..2 = W, 3..5 = A, 6..8 = S, 9..11 = D + # Mapping observed from PH18 HID captures: + # frame[N*4+2] = R, frame[N*4+3] = G, frame[(N+1)*4] = B + frame = [0x00] * 64 + r, g, b = rgb + for emitter in range(12): + frame[emitter * 4 + 2] = r + frame[emitter * 4 + 3] = g + frame[(emitter + 1) * 4] = b + return bytes(frame) + + +def run_magkey_overlay( + node: str, + rgb: tuple[int, int, int], + *, + dry_run: bool, + report_delay: float, +) -> None: + prelude = ( + bytes([0xB1, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x4E]), + bytes([0x08, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF5]), + bytes([0x08, 0x02, 0x4F, 0x0A, 0x32, 0x00, 0x00, 0x6A]), + bytes([0x14, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0xEA]), + bytes([0x13, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0xE4]), + bytes([0x08, 0x02, 0x4F, 0x05, 0x32, 0x08, 0x01, 0x66]), + ) + commit = bytes([0x08, 0x02, 0x4F, 0x05, 0x32, 0x08, 0x01, 0x66]) + payload = build_magkey_payload(rgb) + + print("applying MagKey/WASD overlay") + for packet in prelude: + prefixed = b"\x00" + packet + print("feature ff02 magkey prelude") + print(format_hexdump(prefixed)) + if not dry_run: + ioctl_feature(node, prefixed) + if report_delay > 0: + time.sleep(report_delay) + + print("output magkey frame") + print(format_hexdump(payload)) + if not dry_run: + write_output(node, payload) + if report_delay > 0: + time.sleep(report_delay) + + prefixed_commit = b"\x00" + commit + print("feature ff02 magkey commit") + print(format_hexdump(prefixed_commit)) + if not dry_run: + ioctl_feature(node, prefixed_commit) + if report_delay > 0: + time.sleep(report_delay) + + +def run_keyboard_baseline_all( + nodes: list[str], + rgb: tuple[int, int, int], + *, + dry_run: bool, + report_delay: float, + passes: int, + banks: int, + include_magkeys: bool, +) -> None: + success_count = 0 + for node in nodes: + try: + run_keyboard_baseline( + node, + rgb, + dry_run=dry_run, + report_delay=report_delay, + passes=passes, + banks=banks, + include_magkeys=include_magkeys, + ) + success_count += 1 + except OSError as exc: + print(f"warning: {node} failed: {exc}") + if success_count == 0: + raise SystemExit("failed to write keyboard baseline on all candidate ff02 nodes") + + +def run_keyboard_zones( + ff02_nodes: list[str], + vendor_nodes: list[str], + zone_colors: tuple[tuple[int, int, int], tuple[int, int, int], tuple[int, int, int], tuple[int, int, int]], + *, + dry_run: bool, + report_delay: float, + passes: int, + banks: int, + include_magkeys: bool, +) -> None: + # Anchor keyboard in static mode and clear previous state before per-zone paint. + run_keyboard_baseline_all( + ff02_nodes, + (0, 0, 0), + dry_run=dry_run, + report_delay=report_delay, + passes=passes, + banks=banks, + include_magkeys=False, + ) + + print("applying keyboard 4-zone colors via report84") + for zone_index, key_indexes in enumerate(ZONE_INDEX_GROUPS): + rgb = zone_colors[zone_index] + print(f"zone{zone_index + 1}: rgb={rgb[0]},{rgb[1]},{rgb[2]} indexes={key_indexes.start}-{key_indexes.stop - 1}") + for node in vendor_nodes: + for index in key_indexes: + paint_index_via_report84(node, index, rgb, dry_run=dry_run) + if report_delay > 0: + time.sleep(report_delay) + + if include_magkeys: + for node in ff02_nodes: + run_magkey_overlay(node, zone_colors[0], dry_run=dry_run, report_delay=report_delay) + + +def run_keyboard_dynamic( + nodes: list[str], + *, + dry_run: bool, + repeats: int, + report_delay: float, +) -> None: + # Working sequence confirmed on PH18-73: + # legacy-86-with-blackout + payloads = ( + ("blackout-raw", bytes([0x86, 0x00])), + ("dynamic-raw", bytes([0x86, 0x01])), + ("dynamic-prefixed", bytes([0x00, 0x86, 0x01])), + ) + success_count = 0 + for node in nodes: + for _ in range(max(1, repeats)): + for encoding, payload in payloads: + print(f"{node} feature return-to-dynamic ({encoding})") + print(format_hexdump(payload)) + if not dry_run: + try: + ioctl_feature(node, payload) + success_count += 1 + except OSError as exc: + print(f"warning: {node} {encoding} failed: {exc}") + if report_delay > 0: + time.sleep(report_delay) + if not dry_run and success_count == 0: + raise SystemExit("failed to switch keyboard to dynamic mode on all keyboard nodes") + + +def zone_packets(zone: str, mode: str, rgb: tuple[int, int, int], brightness: int) -> list[bytes]: + selector = 0x00 if zone in {"rear", "logo"} else 0x05 + if mode == "static": + return [ + build_feature([0x14, 0x01, selector, rgb[0], rgb[1], rgb[2], 0x00]), + build_feature([0x08, selector, 0x01, 0x05, brightness, 0x00, 0x01]), + ] + if mode == "off": + return [build_feature([0x08, selector, 0x01, 0x05, 0x00, 0x00, 0x01])] + if mode == "progressbar": + effect = 0x03 if zone == "logo" else 0x5D + return [build_feature([0x08, selector, effect, 0x05, 0x64, 0x08, 0x01])] + raise ValueError(f"unsupported mode '{mode}' for zone '{zone}'") + + +def run_zone_apply( + devices: dict[str, HidrawInfo], + zone: str, + mode: str, + rgb: tuple[int, int, int], + *, + brightness: int, + dry_run: bool, + init: bool, + report_delay: float, +) -> None: + zones = [zone] if zone != "all" else ["rear", "logo", "bar"] + init_payload = bytes([0x41, 0x01] + [0x00] * 62) + + init_done: set[str] = set() + for name in zones: + target = devices[name].node + if init and target not in init_done: + print(f"{target} output init") + print(format_hexdump(init_payload)) + if not dry_run: + write_output(target, init_payload) + init_done.add(target) + if report_delay > 0: + time.sleep(report_delay) + + for packet in zone_packets(name, mode, rgb, brightness): + print(f"{target} feature {name}/{mode}") + print(format_hexdump(packet)) + if not dry_run: + ioctl_feature(target, b"\x00" + packet) + if report_delay > 0: + time.sleep(report_delay) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="PH18 HID RGB utility (experimental)") + sub = parser.add_subparsers(dest="command", required=True) + + sub.add_parser("list", help="list hidraw devices and guessed RGB mapping") + + keyboard = sub.add_parser("keyboard", help="set full-keyboard static baseline via ff02") + keyboard.add_argument("--color", default="00c8ff", help="R,G,B or hex RRGGBB") + keyboard.add_argument("--ff02-device", help="override ff02 hidraw node (e.g. /dev/hidraw5)") + keyboard.add_argument("--dry-run", action="store_true", help="print packets without sending") + keyboard.add_argument( + "--no-magkeys", + action="store_true", + help="do not apply the MagKey/WASD overlay frame after keyboard baseline", + ) + keyboard.add_argument( + "--passes", + type=int, + default=20, + help="anchor loop count (default: 20, improves full-board coverage on some units)", + ) + keyboard.add_argument( + "--banks", + type=int, + default=8, + help="frame writes per pass (default: 8, improves full-board coverage on some units)", + ) + keyboard.add_argument( + "--report-delay", type=float, default=DEFAULT_REPORT_DELAY, help="sleep between writes" + ) + + keyboard_zones = sub.add_parser( + "keyboard-zones", + help="set 4 keyboard zones with distinct colors (experimental report84 overlay)", + ) + keyboard_zones.add_argument("--z1", required=True, help="zone1 color (left), R,G,B or RRGGBB") + keyboard_zones.add_argument("--z2", required=True, help="zone2 color, R,G,B or RRGGBB") + keyboard_zones.add_argument("--z3", required=True, help="zone3 color, R,G,B or RRGGBB") + keyboard_zones.add_argument("--z4", required=True, help="zone4 color (right), R,G,B or RRGGBB") + keyboard_zones.add_argument("--ff02-device", help="override ff02 hidraw node") + keyboard_zones.add_argument("--vendor-device", help="override vendor report84 hidraw node") + keyboard_zones.add_argument("--dry-run", action="store_true", help="print writes without sending") + keyboard_zones.add_argument( + "--no-magkeys", + action="store_true", + help="do not apply the MagKey/WASD overlay (defaults to zone1 color)", + ) + keyboard_zones.add_argument( + "--passes", + type=int, + default=20, + help="anchor loop count before per-zone writes (default: 20)", + ) + keyboard_zones.add_argument( + "--banks", + type=int, + default=8, + help="frame writes per pass before per-zone writes (default: 8)", + ) + keyboard_zones.add_argument( + "--report-delay", + type=float, + default=0.0, + help="sleep between per-key writes (default: 0.0)", + ) + + keyboard_dynamic = sub.add_parser( + "keyboard-dynamic", + help="switch keyboard back to firmware dynamic animation", + ) + keyboard_dynamic.add_argument("--vendor-device", help="override one keyboard hidraw node") + keyboard_dynamic.add_argument("--dry-run", action="store_true", help="print writes without sending") + keyboard_dynamic.add_argument("--repeats", type=int, default=2, help="number of report86 writes per node") + keyboard_dynamic.add_argument( + "--report-delay", type=float, default=DEFAULT_REPORT_DELAY, help="sleep between writes" + ) + + zone = sub.add_parser("zone", help="control rear/logo/bar HID zones") + zone.add_argument("target", choices=["rear", "logo", "bar", "all"]) + zone.add_argument("mode", choices=["static", "off", "progressbar"]) + zone.add_argument("--color", default="00c8ff", help="R,G,B or hex RRGGBB") + zone.add_argument("--brightness", type=int, default=DEFAULT_STATIC_BRIGHTNESS) + zone.add_argument("--dry-run", action="store_true", help="print packets without sending") + zone.add_argument("--no-init", action="store_true", help="skip 41 01 init packet") + zone.add_argument( + "--report-delay", type=float, default=DEFAULT_REPORT_DELAY, help="sleep between writes" + ) + + return parser.parse_args() + + +def main() -> int: + args = parse_args() + devices = collect_hidraw_info() + + if args.command == "list": + print("Detected hidraw devices:") + for info in devices: + print(f" {info.label}") + try: + ff02_nodes = select_ff02_nodes(devices, None) + print("ff02 keyboard candidates:") + for info in ff02_nodes: + print(f" {info.label}") + except SystemExit as exc: + print(f"ff02 keyboard candidates: {exc}") + try: + vendor_nodes = select_vendor_keyboard_nodes(devices, None) + print("vendor report84 candidates:") + for info in vendor_nodes: + print(f" {info.label}") + except SystemExit as exc: + print(f"vendor report84 candidates: {exc}") + try: + zones = select_darfon_nodes(devices) + print(f"rear candidate: {zones['rear'].label}") + print(f"logo candidate: {zones['logo'].label}") + print(f"bar candidate: {zones['bar'].label}") + except SystemExit as exc: + print(f"Darfon candidates: {exc}") + return 0 + + if args.command == "keyboard": + rgb = parse_rgb(args.color) + ff02_nodes = select_ff02_nodes(devices, args.ff02_device) + run_keyboard_baseline_all( + [info.node for info in ff02_nodes], + rgb, + dry_run=args.dry_run, + report_delay=args.report_delay, + passes=max(1, args.passes), + banks=max(1, args.banks), + include_magkeys=not args.no_magkeys, + ) + return 0 + + if args.command == "keyboard-zones": + zone_colors = ( + parse_rgb(args.z1), + parse_rgb(args.z2), + parse_rgb(args.z3), + parse_rgb(args.z4), + ) + ff02_nodes = select_ff02_nodes(devices, args.ff02_device) + vendor_nodes = select_vendor_keyboard_nodes(devices, args.vendor_device) + run_keyboard_zones( + [info.node for info in ff02_nodes], + [info.node for info in vendor_nodes], + zone_colors, + dry_run=args.dry_run, + report_delay=max(0.0, args.report_delay), + passes=max(1, args.passes), + banks=max(1, args.banks), + include_magkeys=not args.no_magkeys, + ) + return 0 + + if args.command == "keyboard-dynamic": + keyboard_nodes = select_keyboard_family_nodes(devices, args.vendor_device) + run_keyboard_dynamic( + [info.node for info in keyboard_nodes], + dry_run=args.dry_run, + repeats=max(1, args.repeats), + report_delay=max(0.0, args.report_delay), + ) + return 0 + + if args.command == "zone": + if not 0 <= args.brightness <= 255: + raise SystemExit("--brightness must be in range 0..255") + rgb = parse_rgb(args.color) + zone_devices = select_darfon_nodes(devices) + run_zone_apply( + zone_devices, + args.target, + args.mode, + rgb, + brightness=args.brightness, + dry_run=args.dry_run, + init=not args.no_init, + report_delay=args.report_delay, + ) + return 0 + + raise SystemExit("invalid command") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ph18_rgb_menu.py b/ph18_rgb_menu.py new file mode 100644 index 0000000..0da8ae9 --- /dev/null +++ b/ph18_rgb_menu.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +"""Interactive menu for PH18 HID RGB controls.""" + +from __future__ import annotations + +import re +import subprocess +import sys +from pathlib import Path + +HEX_COLOR_RE = re.compile(r"^[0-9a-fA-F]{6}$") +CSV_COLOR_RE = re.compile(r"^\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*$") + +SCRIPT_DIR = Path(__file__).resolve().parent +HID_RGB_SCRIPT = SCRIPT_DIR / "ph18_hid_rgb.py" + + +def prompt_menu_choice() -> str: + """Prompt for the main menu action.""" + print("\nPH18 RGB Menu") + print("1. List detected HID devices and mappings") + print("2. Set keyboard single static color") + print("3. Set keyboard 4-zone static colors") + print("4. Switch keyboard to dynamic mode") + print("5. Set rear/logo/bar static color") + print("6. Turn rear/logo/bar off") + print("7. Set rear/logo/bar progressbar effect") + print("0. Exit") + return input("Choose an option: ").strip() + + +def prompt_yes_no(question: str, *, default_yes: bool = True) -> bool: + """Prompt for a yes/no answer.""" + suffix = " [Y/n]: " if default_yes else " [y/N]: " + raw = input(question + suffix).strip().lower() + if not raw: + return default_yes + return raw in {"y", "yes"} + + +def _valid_csv_color(value: str) -> bool: + if not CSV_COLOR_RE.match(value): + return False + try: + parts = [int(part.strip()) for part in value.split(",")] + except ValueError: + return False + return len(parts) == 3 and all(0 <= part <= 255 for part in parts) + + +def prompt_color(question: str) -> str: + """Prompt for a color in hex (RRGGBB) or CSV (R,G,B).""" + while True: + raw = input(f"{question} (RRGGBB or R,G,B): ").strip() + if HEX_COLOR_RE.match(raw): + return raw.lower() + if _valid_csv_color(raw): + return raw + print("Invalid color format.") + + +def prompt_target() -> str: + """Prompt for zone target.""" + options = {"1": "rear", "2": "logo", "3": "bar", "4": "all"} + print("\nTarget:") + print("1. rear") + print("2. logo") + print("3. bar") + print("4. all") + while True: + choice = input("Choose target: ").strip() + target = options.get(choice) + if target: + return target + print("Invalid choice.") + + +def prompt_brightness() -> str: + """Prompt for brightness byte.""" + while True: + raw = input("Brightness [0-255] (default 25): ").strip() + if not raw: + return "25" + try: + value = int(raw, 10) + except ValueError: + print("Invalid number.") + continue + if 0 <= value <= 255: + return str(value) + print("Brightness must be 0..255.") + + +def run_hid_rgb(args: list[str]) -> int: + """Run ph18_hid_rgb.py with provided arguments.""" + cmd = [sys.executable, str(HID_RGB_SCRIPT), *args] + print(f"\n$ {' '.join(cmd)}") + result = subprocess.run(cmd, check=False) + if result.returncode != 0: + print(f"Command failed with exit code {result.returncode}") + return result.returncode + + +def action_keyboard_static() -> None: + color = prompt_color("Keyboard color") + args = ["keyboard", "--color", color] + if not prompt_yes_no("Apply MagKey/WASD overlay color too?", default_yes=True): + args.append("--no-magkeys") + run_hid_rgb(args) + + +def action_keyboard_zones() -> None: + z1 = prompt_color("Zone 1 color (left)") + z2 = prompt_color("Zone 2 color") + z3 = prompt_color("Zone 3 color") + z4 = prompt_color("Zone 4 color (right)") + args = ["keyboard-zones", "--z1", z1, "--z2", z2, "--z3", z3, "--z4", z4] + if not prompt_yes_no("Apply MagKey/WASD overlay from zone1 color?", default_yes=True): + args.append("--no-magkeys") + run_hid_rgb(args) + + +def action_keyboard_dynamic() -> None: + repeats = input("Dynamic repeats (default 2): ").strip() or "2" + run_hid_rgb(["keyboard-dynamic", "--repeats", repeats]) + + +def action_zone_static() -> None: + target = prompt_target() + color = prompt_color("Zone color") + brightness = prompt_brightness() + run_hid_rgb(["zone", target, "static", "--color", color, "--brightness", brightness]) + + +def action_zone_off() -> None: + target = prompt_target() + run_hid_rgb(["zone", target, "off"]) + + +def action_zone_progressbar() -> None: + target = prompt_target() + run_hid_rgb(["zone", target, "progressbar"]) + + +def main() -> int: + """Run the interactive RGB menu.""" + if not HID_RGB_SCRIPT.exists(): + print(f"Missing script: {HID_RGB_SCRIPT}") + return 1 + + if hasattr(sys, "getuid") and sys.getuid() != 0: + print("Tip: run with sudo for write operations (e.g. `sudo python3 ph18_rgb_menu.py`).") + + actions = { + "1": lambda: run_hid_rgb(["list"]), + "2": action_keyboard_static, + "3": action_keyboard_zones, + "4": action_keyboard_dynamic, + "5": action_zone_static, + "6": action_zone_off, + "7": action_zone_progressbar, + } + + while True: + choice = prompt_menu_choice() + if choice == "0": + print("Bye.") + return 0 + action = actions.get(choice) + if action is None: + print("Invalid option.") + continue + action() + + +if __name__ == "__main__": + raise SystemExit(main())