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