Files
2026-05-27 07:09:37 +02:00

358 lines
13 KiB
Python

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