358 lines
13 KiB
Python
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())
|