Initial release
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
__pycache__/
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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())
|
||||||
+787
@@ -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())
|
||||||
@@ -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())
|
||||||
Reference in New Issue
Block a user