Initial release
This commit is contained in:
+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())
|
||||
Reference in New Issue
Block a user