#!/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())