Поддержка MacOS (проверено в Tahoe)

This commit is contained in:
2026-09-06 17:13:34 +08:00
parent 57c4e80f0d
commit 4515b39090
24 changed files with 590 additions and 65 deletions
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""Конвертер дампа USBPcap (pcapng) → лог control-трансферов SET/GET_REPORT.
Использование: ./analyze_pcap.py <capture.pcapng> [out.txt]
"""
import struct
import sys
# тип блока Enhanced Packet в pcapng
BLOCK_ENHANCED_PACKET = 6
# тип transfer'а control в USBPcap
XFER_CONTROL = 2
# биты направления в endpoint
EP_DIR_IN = 0x80 # device → host; иначе host → device
def read_packet_blocks(data: bytes) -> list[bytes]:
"""Извлечь данные всех Enhanced Packet-блоков из pcapng-файла."""
records = []
off = 0
while off + 12 <= len(data):
block_type, block_len = struct.unpack_from("<II", data, off)
if block_len < 12 or off + block_len > len(data):
break
if block_type == BLOCK_ENHANCED_PACKET:
caplen = struct.unpack_from("<I", data, off + 20)[0]
records.append(data[off + 28:off + 28 + caplen])
off += block_len
return records
def parse_usb_header(packet: bytes):
"""Разобрать заголовок USBPcap: → (irpid, endpoint, transfer, payload)."""
header_len = struct.unpack_from("<H", packet, 0)[0]
irpid = struct.unpack_from("<Q", packet, 2)[0]
endpoint = packet[21]
transfer = packet[22]
data_len = struct.unpack_from("<I", packet, 23)[0]
payload = packet[header_len:header_len + data_len]
return irpid, endpoint, transfer, payload
def parse_events(records: list[bytes]) -> list[tuple]:
"""Собрать события SET/GET_REPORT, склеивая setup+data GET-ов по irpid."""
events = []
pending_get = {} # irpid → (iface, request_type, report_id)
for packet in records:
irpid, endpoint, transfer, data = parse_usb_header(packet)
if transfer != XFER_CONTROL or not data:
continue
if (not endpoint & EP_DIR_IN and len(data) >= 8
and data[0] == 0x21 and data[1] == 0x09):
# merged SET_REPORT: setup(8 байт) + payload
wValue = struct.unpack("<H", data[2:4])[0]
wIndex = struct.unpack("<H", data[4:6])[0]
events.append(("SET", wIndex, wValue >> 8, wValue & 0xFF, data[8:]))
elif (endpoint & EP_DIR_IN and len(data) == 8
and data[0] == 0xA1 and data[1] == 0x01):
# setup-часть GET_REPORT: данные придут отдельным пакетом с тем же irpid
wValue = struct.unpack("<H", data[2:4])[0]
wIndex = struct.unpack("<H", data[4:6])[0]
pending_get[irpid] = (wIndex, wValue >> 8, wValue & 0xFF)
elif endpoint & EP_DIR_IN and irpid in pending_get:
iface, request_type, report_id = pending_get.pop(irpid)
events.append(("GET", iface, request_type, report_id, data))
return events
def main():
if len(sys.argv) < 2:
raise SystemExit(__doc__)
src = sys.argv[1]
dst = sys.argv[2] if len(sys.argv) > 2 else "/tmp/tx_log.txt"
with open(src, "rb") as f:
data = f.read()
events = parse_events(read_packet_blocks(data))
with open(dst, "w") as f:
for num, (kind, iface, rtype, rid, payload) in enumerate(events, 1):
f.write(f"{kind} #{num:4d} if={iface} t={rtype} rid={rid:02x} "
f"len={len(payload)}: {payload.hex(' ')}\n")
print("events:", len(events))
if __name__ == "__main__":
main()
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""Диагностика открытия HID-устройства через IOKit напрямую (ctypes).
Берёт путь устройства из hidapi (DevSrvsID:<entry_id> = registry entry ID),
открывает сервис через IORegistryEntryIDMatching + IOHIDDeviceCreate (как
делает сам hidapi) и печатает точный код IOReturn от IOHIDDeviceOpen:
- 0x00000000 — успех (права есть, проблема в hidapi)
- 0xE00002C1 — kIOReturnNotPrivileged (нет разрешения Input Monitoring)
- другой код — иная причина (см. коды IOKit, iokit_common_err).
"""
import ctypes
import ctypes.util
import re
import sys
import hid
iokit = ctypes.CDLL(ctypes.util.find_library("IOKit"))
cf = ctypes.CDLL(ctypes.util.find_library("CoreFoundation"))
# прототипы функций
iokit.IOServiceGetMatchingService.argtypes = [ctypes.c_int, ctypes.c_void_p]
iokit.IOServiceGetMatchingService.restype = ctypes.c_uint
iokit.IORegistryEntryIDMatching.argtypes = [ctypes.c_longlong]
iokit.IORegistryEntryIDMatching.restype = ctypes.c_void_p
iokit.IOObjectRelease.argtypes = [ctypes.c_uint]
iokit.IOObjectRelease.restype = ctypes.c_int
iokit.IOHIDDeviceCreate.argtypes = [ctypes.c_void_p, ctypes.c_uint]
iokit.IOHIDDeviceCreate.restype = ctypes.c_void_p
iokit.IOHIDDeviceOpen.argtypes = [ctypes.c_void_p, ctypes.c_int]
iokit.IOHIDDeviceOpen.restype = ctypes.c_int
iokit.IOHIDDeviceClose.argtypes = [ctypes.c_void_p, ctypes.c_int]
iokit.IOHIDDeviceClose.restype = ctypes.c_int
cf.CFRelease.argtypes = [ctypes.c_void_p]
def main():
"""Найти iface0 клавиатуры через hidapi и открыть её сервис в IOKit."""
paths = [i["path"].decode() for i in hid.enumerate(0x0C45, 0x8006)
if i["interface_number"] == 0]
if not paths:
raise SystemExit("устройство 0C45:8006 не найдено через hidapi")
m = re.fullmatch(r"DevSrvsID:(\d+)", paths[0])
if not m:
raise SystemExit(f"неожиданный формат пути: {paths[0]!r}")
entry_id = int(m.group(1))
service = iokit.IOServiceGetMatchingService(
0, iokit.IORegistryEntryIDMatching(entry_id))
if not service:
raise SystemExit(f"сервис с entry_id {entry_id} не найден в реестре")
print(f"сервис найден: {service:#x}")
dev = iokit.IOHIDDeviceCreate(None, service)
iokit.IOObjectRelease(service)
if not dev:
raise SystemExit("IOHIDDeviceCreate вернул NULL")
print("IOHIDDeviceCreate: ok")
r = iokit.IOHIDDeviceOpen(dev, 0) # kIOHIDOptionsTypeNone
meaning = {0x00000000: "OK (права есть)",
0xE00002C1: "NotPrivileged — нет разрешения Input Monitoring",
}.get(r, "см. коды IOKit (iokit_common_err)")
print(f"IOHIDDeviceOpen: {r:#010x} ({meaning})")
if r == 0:
iokit.IOHIDDeviceClose(dev, 0)
cf.CFRelease(dev)
if __name__ == "__main__":
main()
Executable
+163
View File
@@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""Карта «LED-индекс → физическая клавиша» для GANSS ARDOR_Katana (0c45:8006).
Построена экспериментально: группы индексов красились контрастными цветами,
пользователь сообщал, какие клавиши загорелись (методика и открытые вопросы —
в docs/protocol.md).
Имена клавиш — позиции US-раскладки.
"""
__all__ = ["KEYMAP", "BLIND", "UNKNOWN", "PROBE_COLORS", "KEY_SYMBOL_ALIASES",
"name"]
# Подтверждённая карта (индекс → имя клавиши)
KEYMAP = {
0x01: "Esc",
0x02: "F1", 0x03: "F2", 0x04: "F3", 0x05: "F4",
0x06: "F5", 0x07: "F6", 0x08: "F7", 0x09: "F8",
0x0A: "F9", 0x0B: "F10", 0x0C: "F11", 0x0D: "F12",
0x13: "`(~)", 0x14: "1", 0x15: "2", 0x16: "3", 0x17: "4",
0x18: "5", 0x19: "6", 0x1A: "7", 0x1B: "8", 0x1C: "9", 0x1D: "0",
0x1E: "-(_)", 0x1F: "=(+)",
0x20: "NumLock", 0x21: "Num/", 0x22: "Num*",
0x25: "Tab", 0x26: "Q", 0x27: "W", 0x28: "E", 0x29: "R",
0x2A: "T", 0x2B: "Y", 0x2C: "U", 0x2D: "I", 0x2E: "O", 0x2F: "P",
0x30: "[({)", 0x31: "](})", 0x32: "Num7", 0x33: "Num8", 0x34: "Num9",
0x37: "Caps", 0x38: "A", 0x39: "S", 0x3A: "D", 0x3B: "F",
0x3C: "G", 0x3D: "H", 0x3E: "J", 0x3F: "K",
0x40: "L", 0x41: ";", 0x42: "'", 0x43: "\\",
0x44: "Num4", 0x45: "Num5", 0x46: "Num6",
0x49: "LShift", 0x4A: "Z", 0x4B: "X", 0x4C: "C", 0x4D: "V",
0x4E: "B", 0x4F: "N",
0x50: "M", 0x51: ",", 0x52: ".", 0x53: "/", 0x54: "RShift",
0x55: "Enter", 0x56: "Num1", 0x57: "Num2", 0x58: "Num3",
0x5B: "LCtrl", 0x5C: "LWin", 0x5D: "LAlt",
0x5E: "Space", 0x5F: "RAlt", 0x60: "Fn", 0x61: "Menu", 0x62: "RCtrl",
0x63: "ArrowLeft", 0x64: "ArrowDown", 0x65: "ArrowUp",
0x66: "ArrowRight", 0x67: "Backspace", 0x68: "Num0",
0x69: "Num.(Del)", 0x6A: "NumEnter",
0x70: "PrtSc", 0x71: "ScrLk", 0x73: "Pause", 0x74: "Insert",
0x75: "Home", 0x76: "PgUp", 0x77: "Delete", 0x78: "End",
0x79: "PgDown", 0x7A: "Num-", 0x7B: "Num+",
}
# Слоты, которые красились, но не светятся: ни к какому LED не подключены
BLIND = {0x00, 0x0E, 0x0F, 0x10, 0x11, 0x12, 0x23, 0x24, 0x35, 0x36,
0x47, 0x48, 0x59, 0x5A, 0x6B, 0x6C, 0x6D, 0x6E, 0x6F, 0x72,
0x7C, 0x7D, 0x7E, 0x7F, 0x80, 0x81, 0x82, 0x83, 0x84, 0x85,
0x86, 0x87, 0x88, 0x89, 0x8A, 0x8B, 0x8C, 0x8D, 0x8E}
# Индексы, не идентифицированные на момент снятия карты
UNKNOWN = sorted(set(range(0x00, 0x8F)) - set(KEYMAP) - BLIND)
# Контрастная палитра для картирования (максимально различимые цвета,
# за раз — немного точек)
PROBE_COLORS = [
(0xFF, 0x00, 0x00), # красный
(0x00, 0xFF, 0x00), # зелёный
(0x00, 0x00, 0xFF), # синий
(0xFF, 0xFF, 0x00), # жёлтый
(0xFF, 0x00, 0xFF), # маджента
(0x00, 0xFF, 0xFF), # циан
(0xFF, 0x80, 0x00), # оранжевый
]
# Дополнительные алиасы клавиш: синоним → каноническое имя из KEYMAP
# (в нижнем регистре). Позволяют писать --key tilde=red, --key lbracket=blue.
# Символы-псевдонимы ('[', '~', ';') работают напрямую из KEYMAP.
KEY_SYMBOL_ALIASES = {
# цифровой ряд
"tilde": "`", "grave": "`", "backquote": "`", "backtick": "`",
"minus": "-", "dash": "-", "hyphen": "-",
"equal": "=", "equals": "=",
# основной блок
"lbracket": "[", "leftbracket": "[", "openbracket": "[",
"rbracket": "]", "rightbracket": "]", "closebracket": "]",
"semicolon": ";", "colon": ";",
"quote": "'", "apostrophe": "'", "singlequote": "'",
"backslash": chr(92), "bslash": chr(92),
"comma": ",",
"period": ".", "dot": ".",
"slash": "/", "question": "/",
"lessthan": ",", "greaterthan": ".",
# пробел и служебные
"spacebar": "space",
"capslock": "caps",
"leftshift": "lshift", "shift": "lshift",
"rightshift": "rshift",
"leftctrl": "lctrl", "ctrl": "lctrl",
"rightctrl": "rctrl",
"leftalt": "lalt", "alt": "lalt",
"rightalt": "ralt", "altgr": "ralt",
"windows": "lwin", "super": "lwin", "meta": "lwin",
"menu": "menu", "context": "menu", "apps": "menu",
"enter": "enter", "return": "enter", "cr": "enter",
"tab": "tab",
"escape": "esc",
"backspace": "backspace", "bksp": "backspace",
"delete": "delete", "del": "delete",
"insert": "insert", "ins": "insert",
"home": "home", "end": "end",
"pageup": "pgup", "pgdn": "pgdown", "pagedown": "pgdown",
"printscreen": "prtsc", "prntscr": "prtsc", "pscr": "prtsc",
"scrolllock": "scrlk", "slck": "scrlk",
"pause": "pause", "break": "pause",
"leftarrow": "arrowleft", "rightarrow": "arrowright",
"uparrow": "arrowup", "downarrow": "arrowdown",
"left": "arrowleft", "right": "arrowright",
"up": "arrowup", "down": "arrowdown",
# нумпад
"numlock": "numlock", "numasterisk": "num*", "numtimes": "num*",
"numminus": "num-", "numplus": "num+", "numpoint": "num.",
"numdot": "num.", "numdecimal": "num.", "numdel": "num.",
"numcomma": "num.", "numperiod": "num.",
"numslash": "num/", "numdivide": "num/",
"numenter": "numenter",
"num0": "num0", "num1": "num1", "num2": "num2", "num3": "num3",
"num4": "num4", "num5": "num5", "num6": "num6", "num7": "num7",
"num8": "num8", "num9": "num9",
}
def name(idx: int) -> str:
"""Имя слота: клавиша, «(blind)» для слепых или 0xNN? для неизвестных."""
if idx in KEYMAP:
return KEYMAP[idx]
if idx in BLIND:
return "(blind)"
return f"0x{idx:02x}?"
if __name__ == "__main__":
print("Confirmed keymap:")
for i in sorted(KEYMAP):
print(f" 0x{i:02x}: {KEYMAP[i]}")
print(f"\nBlind slots: {' '.join(f'{i:02x}' for i in sorted(BLIND))}")
print(f"Unidentified ({len(UNKNOWN)}): "
+ " ".join(f"{i:02x}" for i in UNKNOWN))
# алиасы, сгруппированные по клавишам в порядке раскладки
from collections import defaultdict
groups = defaultdict(list)
for alias, canonical in KEY_SYMBOL_ALIASES.items():
groups[canonical].append(alias)
def _idx(canonical: str) -> int:
"""LED-индекс канонического имени (0xFF — если не найдено)."""
for i, lbl in KEYMAP.items():
if lbl.lower() == canonical or lbl.split("(")[0].lower() == canonical:
return i
return 0xFF
print(f"\nKey aliases ({len(KEY_SYMBOL_ALIASES)}):")
for canonical in sorted(groups, key=_idx):
aliases = ", ".join(sorted(groups[canonical]))
print(f" {canonical:12s} <- {aliases}")
Executable
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""Зонд конфиг-канала GANSS ARDOR_Katana (0c45:8006) через hidraw ioctl.
HIDIOCGFEATURE(len) = _IOWR('H', 0x07, len) — размер кодируется в номере ioctl.
Для состояния клавиатуры зонд безопасен: только GET_FEATURE-запросы,
ничего не записывается.
Использование: misc/probe.py [hidrawN]
(по умолчанию — автоопределение всех hidraw-узлов клавиатуры)
"""
import array
import fcntl
import glob
import os
import struct
import sys
# размеры буфера, которые пробуем по очереди для каждого report id
PROBE_SIZES = (33, 65, 21, 17)
def hid_iocgfeature(size: int) -> int:
"""Номер ioctl _IOWR('H', 0x07, size) для чтения feature-репорта."""
return (3 << 30) | (size << 16) | (0x48 << 8) | 0x07
def find_katana_hidraws() -> list[str]:
"""Имена всех hidraw-узлов клавиатуры (0C45:8006)."""
links = glob.glob("/sys/bus/hid/devices/0003:0C45:8006.*/hidraw/hidraw*")
return sorted(os.path.basename(link) for link in links)
def read_report_id(device_path: str, report_id: int, size: int):
"""Прочитать feature-репорт; None при ошибке (нет такого rid и т.п.)."""
fd = os.open(device_path, os.O_RDWR)
try:
buf = array.array("B", bytes(1 + size)) # первый байт = report id
buf[0] = report_id
res = fcntl.ioctl(fd, hid_iocgfeature(len(buf)), buf)
n = struct.unpack("i", res[:4])[0] # ioctl возвращает [count, данные]
return bytes(res[4:4 + n])
except OSError:
return None
finally:
os.close(fd)
def hexs(data: bytes) -> str:
"""Байты в строку вида 'aa bb cc'."""
return " ".join(f"{x:02x}" for x in data)
def main():
targets = [sys.argv[1]] if len(sys.argv) > 1 else find_katana_hidraws()
if not targets:
raise SystemExit("GANSS ARDOR_Katana hidraw not found")
for name in targets:
path = f"/dev/{name}"
print(f"=== {path} ===")
for rid in range(0x01, 0x09):
found = None
for size in PROBE_SIZES:
data = read_report_id(path, rid, size)
if data:
found = (size, data)
break
if found:
size, data = found
print(f" GET_FEATURE id=0x{rid:02x} req_size={size}: "
f"{hexs(data)} ({len(data)} bytes)")
else:
print(f" GET_FEATURE id=0x{rid:02x}: empty")
if __name__ == "__main__":
sys.exit(main())