diff --git a/AGENTS.md b/AGENTS.md index 68e6b37..a7c839b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,15 +15,15 @@ README.md — обзор проекта: возможности, карта документов, быстрый старт, лицензия, предостережение. Тематические документы лежат в docs/: -* INSTALL.md - установка -* COLOR-MODES.md - режимы +* install.md - установка +* color-modes.md - режимы * cli/ - команды (обзор и карта файлов — в cli/README.md) -* EXTENDING.md - расширение -* PROTOCOL.md - протокол -* SCRIPTS.md - скрипты -* RAW-DATA.md - дампы +* extending.md - расширение +* protocol.md - протокол +* scripts.md - скрипты +* raw-data.md - дампы -Пользовательские вопросы — в тематических документах, реверс-инжиниринг — в PROTOCOL.md. +Пользовательские вопросы — в тематических документах, реверс-инжиниринг — в protocol.md. Ссылки между документами — относительные, без якорей на заголовки с кириллицей. diff --git a/README.md b/README.md index fd356e1..d40f46d 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ Подсветка и клавиши управляются HID feature-репортами через штатный драйвер `usbhid` — **печатать можно прямо во время настройки**, драйвер не отцепляется, root не нужен (после разовой установки udev-правила). +macOS поддерживается экспериментально (транспорт IOKit, см. [docs/install.md](docs/install.md) → «Установка на macOS»). + | Характеристика | Значение | | -------------- | ------------------------------------------------------ | | Устройство | GANSS ARDOR_Katana | @@ -22,11 +24,11 @@ ## Быстрый старт ``` -# 1. установка (подробности — docs/INSTALL.md) +# 1. установка (подробности — docs/install.md) python3 -m venv .venv && .venv/bin/pip install pyusb sudo cp 70-ganss-katana.rules /etc/udev/rules.d/ && sudo udevadm trigger -# 2. подсветка (подробности — docs/COLOR-MODES.md) +# 2. подсветка (подробности — docs/color-modes.md) ./katana.py mode breathing --color red # дыхание красным ./katana.py paint --black --wasd dodgerblue # чёрная база, WASD голубым ./katana.py keys # посмотреть, что сейчас светится @@ -43,7 +45,7 @@ sudo cp 70-ganss-katana.rules /etc/udev/rules.d/ && sudo udevadm trigger ./katana.py apply ~/my-katana.yaml # макросы + переназначение + подсветка ``` -Если что-то не работает — загляните в [docs/INSTALL.md](docs/INSTALL.md) (разделы «Проверка устройства» и решение проблем) и в справочник команд [docs/cli/README.md](docs/cli/README.md). +Если что-то не работает — загляните в [docs/install.md](docs/install.md) (разделы «Проверка устройства» и решение проблем) и в справочник команд [docs/cli/README.md](docs/cli/README.md). ## Документация @@ -51,16 +53,16 @@ sudo cp 70-ganss-katana.rules /etc/udev/rules.d/ && sudo udevadm trigger | Документ | Тема | | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ | -| [docs/INSTALL.md](docs/INSTALL.md) | Установка: зависимости, venv, udev-правило, проверка устройства, решение проблем. | -| [docs/COLOR-MODES.md](docs/COLOR-MODES.md) | 19 режимов подсветки: таблица, примеры `mode`, аргументы, направление анимации. | +| [docs/install.md](docs/install.md) | Установка: зависимости, venv, udev-правило, проверка устройства, решение проблем. | +| [docs/color-modes.md](docs/color-modes.md) | 19 режимов подсветки: таблица, примеры `mode`, аргументы, направление анимации. | | [docs/cli/README.md](docs/cli/README.md) | Справочник `katana.py`: именованные цвета, имена клавиш, `paint`/`scan`/`raw`/`remap`/`macro`/`keys`/`reset`, решение проблем. | | [docs/cli/remap.md](docs/cli/remap.md) | Переназначение клавиш: мышь, редактор, горячие клавиши, мультимедиа, макросы. | | [docs/cli/macro.md](docs/cli/macro.md) | Макросы: запись, режимы повтора, XML вендора. | | [docs/cli/apply.md](docs/cli/apply.md) | `apply` — YAML-конфиг: все настройки одним файлом. | -| [docs/EXTENDING.md](docs/EXTENDING.md) | Свои скрипты и анимации: `katana.py` как библиотека, API `Katana`, ограничения скорости и флеша, готовые приёмы. | -| [docs/PROTOCOL.md](docs/PROTOCOL.md) | Протокол для реверсеров: транспорт, транзакции, форматы payload'ов, карта клавиш, статус реверса, грабли, планы. | -| [docs/SCRIPTS.md](docs/SCRIPTS.md) | Состав репозитория и вспомогательные скрипты: `keymap.py`, `analyze_pcap.py`, `probe.py`. | -| [docs/RAW-DATA.md](docs/RAW-DATA.md) | Дампы USB-трафика вендорской утилиты: как снимались, как конвертировать, состав `raw-data/katana-v1/`. | +| [docs/extending.md](docs/extending.md) | Свои скрипты и анимации: `katana.py` как библиотека, API `Katana`, ограничения скорости и флеша, готовые приёмы. | +| [docs/protocol.md](docs/protocol.md) | Протокол для реверсеров: транспорт, транзакции, форматы payload'ов, карта клавиш, статус реверса, грабли, планы. | +| [docs/scripts.md](docs/scripts.md) | Состав репозитория и вспомогательные скрипты: `keymap.py`, `analyze_pcap.py`, `misc/probe.py`. | +| [docs/raw-data.md](docs/raw-data.md) | Дампы USB-трафика вендорской утилиты: как снимались, как конвертировать, состав `raw-data/katana-v1/`. | ## Лицензия @@ -101,6 +103,7 @@ along with this program. If not, see . * AI-модель `koda-pro` через [KodaCode](https://kodacode.ru) * python 3.10 + pyusb + pyyaml +* Linux (hidraw-ioctl) и macOS (IOKit через ctypes, экспериментально) * VirtualBox + Windows 11: * штатная утилита конфигурации клавиатуры * WireShark с установленным `usbpcap` @@ -108,7 +111,7 @@ along with this program. If not, see . ## TODO * переназначение на другие типы действий (Fn-слой) и чтение таблицы переназначений — мышиные действия, шорткаты редактора, горячие клавиши, мультимедиа и макросы реализованы ([docs/cli/remap.md](docs/cli/remap.md), [docs/cli/macro.md](docs/cli/macro.md)) -* макросы: чтение содержимого из прошивки, коллизия кодов громкости и букв B/C, стрелки Up/Down, остальные медиа-коды ([docs/PROTOCOL.md](docs/PROTOCOL.md) → «Макросы») -* пресеты/анимации поверх per-key API — методика описана в [docs/EXTENDING.md](docs/EXTENDING.md). +* макросы: чтение содержимого из прошивки, коллизия кодов громкости и букв B/C, стрелки Up/Down, остальные медиа-коды ([docs/protocol.md](docs/protocol.md) → «Макросы») +* пресеты/анимации поверх per-key API — методика описана в [docs/extending.md](docs/extending.md). -Cм. [docs/PROTOCOL.md](docs/PROTOCOL.md) → «Следующие шаги» +Cм. [docs/protocol.md](docs/protocol.md) → «Следующие шаги» diff --git a/docs/COLOR-MODES.md b/docs/COLOR-MODES.md index 3f1675c..294a4e8 100644 --- a/docs/COLOR-MODES.md +++ b/docs/COLOR-MODES.md @@ -2,7 +2,7 @@ 19 встроенных режимов прошивки плюс выключение. Номер режима = байт [0] lighting-payload, алиасы заданы в `katana.py` (`MODES`). -Полная таблица с байтами payload — в [PROTOCOL.md](PROTOCOL.md) (раздел «Lighting payload»). +Полная таблица с байтами payload — в [protocol.md](protocol.md) (раздел «Lighting payload»). ## Таблица режимов @@ -31,7 +31,7 @@ | 19 | Шаттл | `shuttle` | моно/спектр | + | + | | бегущие строки по чётным/нечётным рядам в обе стороны | | 20 | Выключена | `off` | | | | | все клавиши погашены | -¹ У breathing цвет из payload игнорируется прошивкой: цикл цвета зашит и гоняет зелёно-жёлто-красную гамму при любом `--color` (проверено на железе, payload совпадает с вендорским — подробности в [PROTOCOL.md](PROTOCOL.md)). +¹ У breathing цвет из payload игнорируется прошивкой: цикл цвета зашит и гоняет зелёно-жёлто-красную гамму при любом `--color` (проверено на железе, payload совпадает с вендорским — подробности в [protocol.md](protocol.md)). Для моноцветного дыхания аналога нет; если нужен именно красный постоянный свет — используйте `static`. ## Примеры @@ -99,4 +99,4 @@ Задаётся `--direction north-south|south-north|east-west|west-east` (байт [11] payload). Значения сняты для `waterfall` (02/03) и `marquee` (01/00). Для `flow`, `rotate`, `wind` применяйте те же опции — значения байта уточняются. -Подробности — [PROTOCOL.md](PROTOCOL.md) (раздел «Байт [11] — подрежим»). +Подробности — [protocol.md](protocol.md) (раздел «Байт [11] — подрежим»). diff --git a/docs/EXTENDING.md b/docs/EXTENDING.md index 84d554e..0afda21 100644 --- a/docs/EXTENDING.md +++ b/docs/EXTENDING.md @@ -4,7 +4,7 @@ Модуль спроектирован как библиотека: класс `Katana` и вспомогательные функции импортируются в любой ваш скрипт одной строкой. Документ описывает программный интерфейс, его ограничения и готовые приёмы. -Справка по CLI-командам — в [cli/README.md](cli/README.md), низкоуровневые детали протокола — в [PROTOCOL.md](PROTOCOL.md). +Справка по CLI-командам — в [cli/README.md](cli/README.md), низкоуровневые детали протокола — в [protocol.md](protocol.md). ## Что даёт per-key API @@ -225,4 +225,4 @@ if __name__ == "__main__": - визуализация музыки через любой FFT-вход: спектр по рядам клавиатуры; - световой «миникарт»: подсветка зоны WASD в играх, где это уместно. -Список открытых направлений самого протокола (переназначение клавиш, байты направлений) — в [PROTOCOL.md](PROTOCOL.md) → «Следующие шаги». +Список открытых направлений самого протокола (переназначение клавиш, байты направлений) — в [protocol.md](protocol.md) → «Следующие шаги». diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 4f043f6..979765a 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -7,7 +7,7 @@ description: Установка, udev-правило и решение проб ## Зависимости Нужен Python 3.10+. -Зависимости: `pyusb` (для `reset`) и `pyyaml` (для `apply`); сам транспорт работает на голых `fcntl`-ioctl'ах к `/dev/hidraw*`: +Зависимости: `pyusb` (для `reset`) и `pyyaml` (для `apply`); сам транспорт работает на голых `fcntl`-ioctl'ах к `/dev/hidraw*` (на Linux) или через hidapi (на macOS, см. ниже): ``` python3 -m venv .venv @@ -54,3 +54,27 @@ ID `0c45:8006` принадлежит чипу Sonix SN32, который про Переподключите клавиатуру. Если пользователь не в группе `plugdev` — `sudo usermod -aG plugdev $USER` и перелогин. + +## Установка на macOS + +Экспериментальная поддержка: транспорт работает через IOKit напрямую (ctypes), +сторонние HID-библиотеки не нужны, udev-правило не нужно. + +``` +python3 -m venv .venv +.venv/bin/pip install pyusb pyyaml +``` + +macOS выдаёт доступ к HID-клавиатурам вручную: +**Системные настройки → Конфиденциальность и безопасность → «Input Monitoring»** — +добавьте приложение, из которого запускается `katana.py` (Terminal, iTerm2, VS Code), +включите его и **перезапустите приложение** (разрешение подхватывается только новым процессом). +Без разрешения `katana.py` выведет подсказку об этом. + +Важно: утилиты, перехватывающие клавиатуры (Karabiner-Elements и подобные), +открывают их в монопольном режиме — пока Karabiner работает, открыть устройство нельзя +(ошибка `kIOReturnExclusiveAccess`). Выйдите из Karabiner на время настройки +или исключите ARDOR_Katana в его настройках (вкладка Devices). +Проверить, кто мешает, можно диагностикой `misc/iokit_probe.py` (см. [scripts.md](scripts.md)). + +Транспорт выбирается автоматически: на macOS — IOKit, на Linux — hidraw-ioctl. diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 1e17b8a..0c7d10a 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -18,6 +18,30 @@ Feature-репорты через hidraw ioctl (`HIDIOCSFEATURE`/`HIDIOCGFEATURE **usbhid не отцепляется** — печать никогда не прерывается. pyusb-вариант НЕ использовать (отцеплял драйвер). +### macOS (экспериментально, класс `KatanaMacOS`) + +Тот же протокол поверх IOKit напрямую (ctypes, без сторонних библиотек): +`IOHIDDeviceSetReport` / `IOHIDDeviceGetReport` с `kIOHIDReportTypeFeature` — +семантика 1:1 с hidraw, feature-репорты rid=0, 64 байта данных. +Устройство ищется через `IOServiceGetMatchingServices("IOHIDDevice")` + `IOHIDDeviceGetProperty`: +выбирается клавиатура 0C45:8006 с PrimaryUsagePage=1, PrimaryUsage=6 (boot keyboard = интерфейс 0). +Выбор транспорта автоматический по `sys.platform` (`open_katana()`): darwin → IOKit, остальное → hidraw. + +Грабли macOS (все проверены на железе): +- у IOKit своя нумерация типов репортов: Input=0, Output=1, **Feature=2**; + с Output прошивка отвечает мусором (1 байт `04`); +- `IOServiceMatching` возвращает CFDictionaryRef — без явного `restype = c_void_p` + ctypes обрезает указатель и процесс падает segfault'ом; +- `IOHIDDeviceGetProperty` принимает IOHIDDeviceRef (от `IOHIDDeviceCreate`), + а не registry entry — с entry процесс падает segfault'ом; + плоских `idVendor` в свойствах реестра нет — только через GetProperty; +- доступ к HID-клавиатурам выдаётся вручную: Системные настройки → Конфиденциальность и безопасность → «Input Monitoring» для приложения-терминала, затем перезапуск терминала; +- Karabiner-Elements (и подобные утилиты) захватывает клавиатуры монопольно — пока он работает, `IOHIDDeviceOpen` возвращает `kIOReturnExclusiveAccess` (0xE00002C5); проверено диагностикой `misc/iokit_probe.py`; +- `reset` (pyusb) на macOS работает (проверено: USB port reset проходит); +- udev-правило не нужно. + +Проверка на железе (macOS, Karabiner выключен): mode/paint/keys/remap/macro/reset — работают. + ## Общая транзакция (все команды) ``` @@ -65,7 +89,7 @@ CLI: `--rainbow`, `--direction north-south|south-north|east-west|west-east`. - Яркость масштабирует per-key цвета: 15/15 → `ee` (238), 1/15 → `0f`. - Режим 0x80 отображает таблицу per-key (см. ниже) — это и есть «кастом». -### Таблица режимов (описания вендорской утилиты, docs/COLOR-MODES.md) +### Таблица режимов (описания вендорской утилиты, docs/color-modes.md) Номер = байт [0] payload. «моно/спектр» — режим рисует одиночный цвет или полный спектр независимо от него. @@ -535,7 +559,7 @@ ACTION: мышь (`lmb`/`mouse1`, `rmb`/`mouse2`, `mmb`/`mouse3`, `back`/`mouseb Шорткаты редактора (тип `02`) тоже сняты и реализованы (дамп keybind-editor). Горячие клавиши (тип `02`, маска модификаторов в байте [1]) сняты по дампу keybind-hotkeys и реализованы (`HOTKEY_KEYS`/`HOTKEY_MODIFIERS`, `remap --key KEY=MOD+KEY`). Осталось: другие типы действий (Fn-слой) и чтение таблицы. -5. ~~Демо-скрипты (радуга, волна) поверх per-key API + живое чтение f5~~ — ЗАВЕРШЕНО, методика описана и проверена ([EXTENDING.md](EXTENDING.md)): таблица обновляется живьём в режиме 0x80, ~1 кадр/с. +5. ~~Демо-скрипты (радуга, волна) поверх per-key API + живое чтение f5~~ — ЗАВЕРШЕНО, методика описана и проверена ([extending.md](extending.md)): таблица обновляется живьём в режиме 0x80, ~1 кадр/с. 6. ~~Макросы: запись содержимого и привязка~~ — ЗАВЕРШЕНО (канал 04 19 + записи `[06, ...]` в 04 11, команда `macro`, см. «Макросы»). Осталось: чтение содержимого, коллизия кодов 05/06, стрелки Up/Down, остальные медиа-коды. diff --git a/docs/RAW-DATA.md b/docs/RAW-DATA.md index 6581bda..7394074 100644 --- a/docs/RAW-DATA.md +++ b/docs/RAW-DATA.md @@ -1,6 +1,6 @@ # Дампы GANSS ARDOR_Katana (katana-v1) -Сырые данные, на которых построен реверс протокола (см. [PROTOCOL.md](PROTOCOL.md)). +Сырые данные, на которых построен реверс протокола (см. [protocol.md](protocol.md)). Устройство: клавиатура GANSS ARDOR_Katana, USB `0C45:8006` (Sonix SN32). ## Состав @@ -56,7 +56,7 @@ На Linux, из корня репозитория: ``` -python3 analyze_pcap.py raw-data/katana-v1/color-modes.pcapng raw-data/katana-v1/color-modes.plain.txt +./misc/analyze_pcap.py raw-data/katana-v1/color-modes.pcapng raw-data/katana-v1/color-modes.plain.txt ``` Формат строки лога: @@ -82,9 +82,9 @@ usb-devices | grep -B 2 -A 8 'Vendor=0c45' > usb-devices-output.txt Конфигурационный канал — feature-репорты интерфейса 0 (эндпоинты управления, не interrupt). В `lsusb` устройство подписывается «Microdia Dual Mode Camera (8006 VGA)» — это норма: ID `0c45:8006` чипа Sonix SN32 используется и вебкамерами, и клавиатурами, а база `usb.ids` знает за ним только камеру. -Подробнее — в [INSTALL.md](INSTALL.md) → «Проверка устройства». +Подробнее — в [install.md](install.md) → «Проверка устройства». ## Замечания - Имена файлов оригинальные, включая опечатку `color-pesets.pcapng` (содержимое соответствует color-presets). -- Расшифровка протокола по этим дампам — в [PROTOCOL.md](PROTOCOL.md), парсер — `analyze_pcap.py`, рабочая утилита — `katana.py` (обзор — в [SCRIPTS.md](SCRIPTS.md)). +- Расшифровка протокола по этим дампам — в [protocol.md](protocol.md), парсер — `analyze_pcap.py`, рабочая утилита — `katana.py` (обзор — в [scripts.md](scripts.md)). diff --git a/docs/SCRIPTS.md b/docs/SCRIPTS.md index f9186f7..6050f57 100644 --- a/docs/SCRIPTS.md +++ b/docs/SCRIPTS.md @@ -1,18 +1,20 @@ # Скрипты репозитория Состав репозитория и назначение каждого скрипта. -Использование `katana.py` как библиотеки (свои скрипты и анимации) — в [EXTENDING.md](EXTENDING.md). +Использование `katana.py` как библиотеки (свои скрипты и анимации) — в [extending.md](extending.md). ## Структура репозитория ``` . -├── katana.py Основная CLI-утилита управления подсветкой -├── keymap.py Карта «LED-индекс → физическая клавиша» (104 клавиши) -├── katana.yaml.example Пример YAML-конфига для команды apply -├── analyze_pcap.py Парсер USBPcap-захватов → лог control-трансферов -├── probe.py Исследовательский зонд: чтение feature-репортов ├── 70-ganss-katana.rules udev-правило: доступ к устройству без root +├── katana.yaml.example Пример YAML-конфига для команды apply +├── katana.py Основная CLI-утилита управления подсветкой +├── misc/ +│ ├── keymap.py Карта «LED-индекс → физическая клавиша» (104 клавиши) +│ ├── analyze_pcap.py Парсер USBPcap-захватов → лог control-трансферов +│ ├── probe.py Исследовательский зонд: чтение feature-репортов +│ └── iokit_probe.py Диагностика открытия HID на macOS (код IOReturn) ├── tests/ Офлайн-тесты (без железа) ├── docs/ Документация (см. README.md) └── raw-data/ @@ -29,7 +31,7 @@ ``` Печатает подтверждённую карту, список слепых слотов (39 шт — резерв прошивки, ни к какому LED не подключены), непроидентифицированные индексы и таблицу алиасов клавиш (`KEY_SYMBOL_ALIASES`). -Методика картирования и открытые вопросы — в [PROTOCOL.md](PROTOCOL.md) (раздел «Карта клавиш»). +Методика картирования и открытые вопросы — в [protocol.md](protocol.md) (раздел «Карта клавиш»). ## analyze_pcap.py @@ -41,19 +43,34 @@ ``` Без второго аргумента пишет `/tmp/tx_log.txt`. -Формат строк и методика снятия захватов — в [RAW-DATA.md](RAW-DATA.md). +Формат строк и методика снятия захватов — в [raw-data.md](raw-data.md). -## probe.py +## misc/probe.py Исследовательский зонд времён начала реверса: перебирает report id и читает feature-репорты (только GET, состояние клавиатуры не меняет). Полезен как минимальный пример hidraw-транспорта: ``` -./probe.py [hidrawN] +./misc/probe.py [hidrawN] ``` Без аргумента находит все hidraw-узлы клавиатуры сам. +## misc/iokit_probe.py + +Диагностика macOS-only: открывает HID-сервис клавиатуры через IOKit напрямую +(ctypes, сторонних библиотек не нужно) и печатает точный код `IOReturn` от +`IOHIDDeviceOpen`. Полезен, когда katana.py сообщает только «open failed»: + +``` +.venv/bin/python misc/iokit_probe.py +``` + +Интерпретация результата: `0x00000000` — права есть (проблема в софте), +`0xE00002C1` — нет разрешения «Input Monitoring», +`0xE00002C5` (`kIOReturnExclusiveAccess`) — устройство захвачено монопольно +(типичный случай — Karabiner-Elements). + ## tests/ Офлайн-тесты (железо не нужно): @@ -63,6 +80,7 @@ python3 tests/test_macro.py # макросы, remap-действия, св python3 tests/test_apply.py # apply: YAML-конфиг → argv команд python3 tests/test_parsers.py # парсеры, payload, ACK, keymap python3 tests/test_cli.py # команды CLI на подменном устройстве +python3 tests/test_transport.py # выбор транспорта Linux/macOS, IOKit-обмен ``` `test_macro.py` — блоб, собранный из вендорских XML, сверяется побайтово с дампом macros-create, плюс разбор токенов, round-trip XML, hotkey/remap-действия и сверка с дампами keybind-editor/keybind-hotkeys/keybind-multimedia. diff --git a/docs/cli/README.md b/docs/cli/README.md index d7bad41..c049811 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -2,10 +2,10 @@ Справочник команд и аргументов. Все команды идут через транзакцию `begin → data → commit → save` и безопасны: печать не прерывается, прошивка остаётся живой. -Исключение — `macro set`/`macro clear`: вендор после записи макросов save не делает (подробности в [PROTOCOL.md](../PROTOCOL.md)). -Низкоуровневое описание транзакций — в [PROTOCOL.md](../PROTOCOL.md). +Исключение — `macro set`/`macro clear`: вендор после записи макросов save не делает (подробности в [protocol.md](../protocol.md)). +Низкоуровневое описание транзакций — в [protocol.md](../protocol.md). -Режимы подсветки и их аргументы вынесены в отдельный документ: [COLOR-MODES.md](../COLOR-MODES.md). +Режимы подсветки и их аргументы вынесены в отдельный документ: [color-modes.md](../color-modes.md). ## Состав справочника diff --git a/docs/cli/apply.md b/docs/cli/apply.md index c5568ea..15ae614 100644 --- a/docs/cli/apply.md +++ b/docs/cli/apply.md @@ -8,7 +8,7 @@ ./katana.py apply katana.yaml.example # применить пример из репозитория ``` -Зависимость: PyYAML (`pip install pyyaml`, см. [INSTALL.md](../INSTALL.md)). +Зависимость: PyYAML (`pip install pyyaml`, см. [install.md](../install.md)). ## Порядок применения и сохранение @@ -72,7 +72,7 @@ paint: brightness: 15 ``` -### `lighting` — режим подсветки (см. [COLOR-MODES.md](../COLOR-MODES.md)) +### `lighting` — режим подсветки (см. [color-modes.md](../color-modes.md)) `mode` — имя или номер 1..19, а также `off` и `default` (применяются как команды `off`/`default`). diff --git a/docs/cli/macro.md b/docs/cli/macro.md index cabe2fc..0a3b707 100644 --- a/docs/cli/macro.md +++ b/docs/cli/macro.md @@ -1,7 +1,7 @@ # macro — макросы Записывает содержимое макросов в прошивку (канал 04 19) и работает с вендорским XML. -Формат протокола — в [PROTOCOL.md](../PROTOCOL.md). +Формат протокола — в [protocol.md](../protocol.md). Макрос нужно привязать к клавише через `remap --key КЛЮЧ=macro` (индексация 0-based: «Макрос 1» вендорской утилиты = 0). diff --git a/docs/cli/paint.md b/docs/cli/paint.md index 177f6f6..c136a5f 100644 --- a/docs/cli/paint.md +++ b/docs/cli/paint.md @@ -91,7 +91,7 @@ Shorthand для `--key`: покрасить горизонтальный ряд ### `--no-save` -Не писать во флеш (см. `mode --no-save` в [COLOR-MODES.md](../COLOR-MODES.md)). +Не писать во флеш (см. `mode --no-save` в [color-modes.md](../color-modes.md)). По умолчанию save выполняется. Без `--all` и `--key` команда завершается ошибкой. diff --git a/docs/cli/raw.md b/docs/cli/raw.md index 27e10ee..ef19a65 100644 --- a/docs/cli/raw.md +++ b/docs/cli/raw.md @@ -10,7 +10,7 @@ Payload до 64 байт (пробелы допустимы). ### `--no-save` -Не писать во флеш (см. `mode --no-save` в [COLOR-MODES.md](../COLOR-MODES.md)). +Не писать во флеш (см. `mode --no-save` в [color-modes.md](../color-modes.md)). По умолчанию save выполняется. ``` @@ -20,4 +20,4 @@ Payload до 64 байт (пробелы допустимы). Payload отправляется внутри штатной транзакции `begin → data → commit → save`. Для экспериментов с неописанными байтами. -Формат payload'ов и назначение байтов — в [PROTOCOL.md](../PROTOCOL.md). +Формат payload'ов и назначение байтов — в [protocol.md](../protocol.md). diff --git a/docs/cli/remap.md b/docs/cli/remap.md index a97959f..e80485f 100644 --- a/docs/cli/remap.md +++ b/docs/cli/remap.md @@ -1,6 +1,6 @@ # remap — переназначение клавиш -Переназначает клавиши на кнопки мыши, функции текстового редактора, мультимедиа/веб-действия, эмуляцию горячих клавиш и макросы (канал 04 11, формат — в [PROTOCOL.md](../PROTOCOL.md)). +Переназначает клавиши на кнопки мыши, функции текстового редактора, мультимедиа/веб-действия, эмуляцию горячих клавиш и макросы (канал 04 11, формат — в [protocol.md](../protocol.md)). ``` ./katana.py remap --key caps=lmb # Caps → левая кнопка мыши @@ -121,7 +121,7 @@ ### `--no-save` -Не писать во флеш (см. `mode --no-save` в [COLOR-MODES.md](../COLOR-MODES.md)). +Не писать во флеш (см. `mode --no-save` в [color-modes.md](../color-modes.md)). **Важно:** каждый вызов перезаписывает ВСЮ таблицу переназначений (как вендорская утилита). Переназначения, не указанные в текущем вызове `--key`, сбрасываются в default. diff --git a/docs/cli/scan.md b/docs/cli/scan.md index 56c6b8e..d693dc4 100644 --- a/docs/cli/scan.md +++ b/docs/cli/scan.md @@ -19,4 +19,4 @@ katana.py scan --from 1 --to 19 --delay 2 В конце печатается список принятых и отклонённых прошивкой режимов. Полезно при реверсе: смотреть на клавиатуру и записывать, какой номер какой эффект показывает. -Сами режимы и их названия — в [COLOR-MODES.md](../COLOR-MODES.md). +Сами режимы и их названия — в [color-modes.md](../color-modes.md). diff --git a/docs/cli/troubleshooting.md b/docs/cli/troubleshooting.md index 7e3eb3e..0113e1d 100644 --- a/docs/cli/troubleshooting.md +++ b/docs/cli/troubleshooting.md @@ -3,4 +3,4 @@ - Команда отклоняется со статусом `00`/`ff` — сессия залипла. Сначала повторить команду, затем `katana.py reset` (переподключение порта ~2–12 с, клавиатура не отваливается от системы). - `reset` не помог — выключить/включить клавиатуру физически. -- Подробности и все известные грабли — в [PROTOCOL.md](../PROTOCOL.md) (раздел «Важные грабли»). +- Подробности и все известные грабли — в [protocol.md](../protocol.md) (раздел «Важные грабли»). diff --git a/katana.py b/katana.py index a882555..fcb93b5 100755 --- a/katana.py +++ b/katana.py @@ -1,10 +1,10 @@ #!/usr/bin/env python3 """Управление RGB-подсветкой GANSS ARDOR_Katana (0c45:8006) из Linux. -Полная документация протокола — docs/PROTOCOL.md, карта «LED-индекс → клавиша» — +Полная документация протокола — docs/protocol.md, карта «LED-индекс → клавиша» — keymap.py, справочник команд — docs/CLI.md. -Краткая справка (подробности в PROTOCOL.md): +Краткая справка (подробности в protocol.md): - Транспорт: HID feature-репорты rid=0 через ioctl к /dev/hidrawN (драйвер usbhid не отцепляется, печать не прерывается). НИКОГДА не использовать pyusb с detach_kernel_driver. @@ -45,6 +45,8 @@ keymap.py, справочник команд — docs/CLI.md. ./katana.py reset # разблокировать прошивку без переподключения """ import argparse +import ctypes +import ctypes.util import fcntl import glob import os @@ -96,7 +98,7 @@ MOUSE_ACTIONS = { # шорткаты Ctrl+<буква>, код в байте [2] — HID usage ID буквы, порядок # транзакций в дампе совпадает с порядком списка в UI. # Байт [1] = 01 — модификатор (наблюдался только Ctrl); другие модификаторы -# и произвольные шорткаты не исследованы (см. PROTOCOL.md). +# и произвольные шорткаты не исследованы (см. protocol.md). EDITOR_ACTIONS = { "open": 0x12, # Открыть (Ctrl+O) "new": 0x11, # Создать (Ctrl+N) @@ -214,7 +216,7 @@ VK_TO_MOUSE = {1: 0x01, 2: 0x04, 3: 0x02} # Обратные таблицы: код → имя / код → VK (для показа и экспорта XML). # Коды 0x05/0x06 двусмысленны: это VolUp/VolDown прошивки и одновременно -# HID-коды букв B/C (коллизия кодового пространства, см. PROTOCOL.md). +# HID-коды букв B/C (коллизия кодового пространства, см. protocol.md). # При показе и экспорте предпочитаем медиа-имена, как в вендорском XML. MACRO_CODE_NAMES = {0x05: "volup", 0x06: "voldown", 0x5C: "left", 0x5D: "up", 0x5E: "right", 0x5F: "down"} @@ -237,7 +239,7 @@ FLAG_DEFAULTS = {1: 0x00, 4: 0x00} # для остальных режимов # ---- таблица режимов и алиасы ---------------------------------------------- # Номер режима → (алиас, описание). Порядок совпадает со списком вендорской -# утилиты (docs/COLOR-MODES.md). «моно/спектр» — режим рисует одиночный --color +# утилиты (docs/color-modes.md). «моно/спектр» — режим рисует одиночный --color # или полный спектр независимо от него. MODES = { 1: ("static", "Постоянный свет всех клавиш (моно/спектр)"), @@ -467,7 +469,7 @@ def resolve_key(name: str): return None if _KEY_ALIASES is None: try: - from keymap import KEYMAP, KEY_SYMBOL_ALIASES + from misc.keymap import KEYMAP, KEY_SYMBOL_ALIASES except ImportError: return None _KEY_ALIASES = {} @@ -782,6 +784,168 @@ class Katana: "(try 'reset')") +# ---- IOKit-транспорт (macOS) ----------------------------------------------------- + +def _mac_load_iokit(): + """Загрузить IOKit/CoreFoundation и настроить прототипы функций (один раз).""" + iokit = ctypes.CDLL(ctypes.util.find_library("IOKit")) + cf = ctypes.CDLL(ctypes.util.find_library("CoreFoundation")) + # перечисление и создание устройства + iokit.IOServiceGetMatchingServices.argtypes = [ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p] + iokit.IOServiceGetMatchingServices.restype = ctypes.c_int + # ВАЖНО: IOServiceMatching возвращает CFDictionary (указатель) — без + # явного restype ctypes обрежет его до int и упадёт segfault'ом. + iokit.IOServiceMatching.restype = ctypes.c_void_p + iokit.IOServiceMatching.argtypes = [ctypes.c_char_p] + iokit.IOIteratorNext.argtypes = [ctypes.c_uint] + iokit.IOIteratorNext.restype = ctypes.c_uint + iokit.IOObjectRelease.argtypes = [ctypes.c_void_p] + iokit.IORegistryEntryCreateCFProperties.argtypes = [ + ctypes.c_uint, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_int] + iokit.IORegistryEntryCreateCFProperties.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 + # feature-репорты: (device, reportType, reportID, buffer, bufferLen) + iokit.IOHIDDeviceSetReport.argtypes = [ctypes.c_void_p, ctypes.c_int, + ctypes.c_int, ctypes.c_void_p, ctypes.c_long] + iokit.IOHIDDeviceSetReport.restype = ctypes.c_int + iokit.IOHIDDeviceGetReport.argtypes = [ctypes.c_void_p, ctypes.c_int, + ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p] + iokit.IOHIDDeviceGetReport.restype = ctypes.c_int + # свойства устройства: IOHIDDeviceGetProperty(dev, CFStringRef) -> CFTypeRef + iokit.IOHIDDeviceGetProperty.argtypes = [ctypes.c_void_p, ctypes.c_void_p] + iokit.IOHIDDeviceGetProperty.restype = ctypes.c_void_p + # CoreFoundation: чтение свойств реестра + cf.CFDictionaryGetValue.argtypes = [ctypes.c_void_p, ctypes.c_void_p] + cf.CFDictionaryGetValue.restype = ctypes.c_void_p + cf.CFNumberGetValue.argtypes = [ctypes.c_void_p, ctypes.c_long, ctypes.c_void_p] + cf.CFNumberGetValue.restype = ctypes.c_bool + cf.CFStringCreateWithCString.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_long] + cf.CFStringCreateWithCString.restype = ctypes.c_void_p + cf.CFRelease.argtypes = [ctypes.c_void_p] + return iokit, cf + + +def _mac_find_service(iokit, cf): + """IOKit-сервис интерфейса 0 клавиатуры (конфиг-канал). + + Устройства перечисляет IOServiceGetMatchingServices("IOHIDDevice"); + свойства читает IOHIDDeviceGetProperty (в реестре idVendor нет — это + свойство устройства, а не записи). Интерфейс различаем по usage: + конфиг-канал — boot keyboard (page 1, usage 6), тот же, что и :1.0 + в Linux (см. find_hidraw). + """ + kCFStringEncodingUTF8, kCFNumberSInt32Type = 0x08000100, 3 + + def get_int(dev, key): + v = iokit.IOHIDDeviceGetProperty( + dev, cf.CFStringCreateWithCString(None, key.encode(), + kCFStringEncodingUTF8)) + if not v: + return None + out = ctypes.c_int32() + cf.CFNumberGetValue(v, kCFNumberSInt32Type, ctypes.byref(out)) + return out.value + + it = ctypes.c_uint() + kr = iokit.IOServiceGetMatchingServices( + 0, iokit.IOServiceMatching(b"IOHIDDevice"), ctypes.byref(it)) + if kr != 0: + raise SystemExit(f"macOS: IOServiceGetMatchingServices failed {kr:#x}") + while True: + entry = iokit.IOIteratorNext(it) + if not entry: + break + # IOHIDDeviceGetProperty принимает IOHIDDeviceRef, а не registry + # entry — создаём хэндл через IOHIDDeviceCreate и освобождаем его. + dev = iokit.IOHIDDeviceCreate(None, entry) + if dev: + match = (get_int(dev, "VendorID") == VID + and get_int(dev, "ProductID") == PID + and get_int(dev, "PrimaryUsagePage") == 1 + and get_int(dev, "PrimaryUsage") == 6) + cf.CFRelease(dev) + if match: + return entry + iokit.IOObjectRelease(entry) + raise SystemExit( + f"GANSS ARDOR_Katana ({VID:04x}:{PID:04x}) IOKit iface0 not found") + + +class KatanaMacOS(Katana): + """Тот же протокол, но обмен через IOKit напрямую (macOS, без hidapi). + + Унаследованная логика команд (_cmd, lighting, write_key_table, ...) + вызывает только _set/_get/close — их и переопределяем. + IOHIDDeviceSetReport/GetReport с kIOHIDReportTypeFeature=2 соответствуют + HIDIOCSFEATURE/HIDIOCGFEATURE из Linux-транспорта: те же feature-репорты + rid=0, 64 байта данных. ВНИМАНИЕ: у IOKit своя нумерация типов репортов + (Input=0, Output=1, Feature=2) — с Output=1 прошивка отвечает мусором. + """ + + REPORT_FEATURE = 2 # kIOHIDReportTypeFeature (не путать с Output=1!) + + def __init__(self): + self.iokit, self.cf = _mac_load_iokit() + service = _mac_find_service(self.iokit, self.cf) + self.dev = self.iokit.IOHIDDeviceCreate(None, service) + self.iokit.IOObjectRelease(service) + if not self.dev: + raise SystemExit("macOS: IOHIDDeviceCreate failed") + r = self.iokit.IOHIDDeviceOpen(self.dev, 0) + if r != 0: + self.cf.CFRelease(self.dev) + self.dev = None + # 0xE00002C1 = NotPrivileged, 0xE00002C5 = ExclusiveAccess + hint = ("1. Grant 'Input Monitoring' permission to your terminal " + "app (System Settings → Privacy & Security → Input " + "Monitoring), then restart the terminal.\n" + "2. If it still fails, another app holds the keyboard " + "exclusively — quit Karabiner-Elements (or exclude this " + "device in its Devices settings) and retry.") + extra = "" + if r == 0xE00002C5: + extra = (" (kIOReturnExclusiveAccess — клавиатуру захватил " + "другой процесс, скорее всего Karabiner-Elements)") + raise SystemExit(f"macOS: IOHIDDeviceOpen failed {r:#010x}{extra}\n{hint}") + + def close(self): + if getattr(self, "dev", None) is not None: + self.iokit.IOHIDDeviceClose(self.dev, 0) + self.cf.CFRelease(self.dev) + self.dev = None + + def _set(self, payload: bytes): + """Отправить feature-репорт (SET_REPORT), rid=0.""" + r = self.iokit.IOHIDDeviceSetReport( + self.dev, self.REPORT_FEATURE, 0, bytes(payload), len(payload)) + time.sleep(DELAY) + if r != 0: + raise SystemExit(f"macOS: IOHIDDeviceSetReport failed {r:#010x}") + + def _get(self) -> bytes: + """Прочитать feature-репорт (GET_REPORT), rid=0 → 64 байта данных.""" + buf = (ctypes.c_ubyte * 64)() + n = ctypes.c_long(64) + r = self.iokit.IOHIDDeviceGetReport( + self.dev, self.REPORT_FEATURE, 0, buf, ctypes.byref(n)) + time.sleep(DELAY) + if r != 0: + raise SystemExit(f"macOS: IOHIDDeviceGetReport failed {r:#010x}") + return bytes(buf[:n.value]) + + +def open_katana(): + """Фабрика транспорта: IOKit на macOS, hidraw-ioctl на остальном.""" + if sys.platform == "darwin": + return KatanaMacOS() + return Katana() + + # ---- макросы: блоб, токены, XML ------------------------------------------------- def build_macro_blob(macros): @@ -1130,7 +1294,7 @@ def dispatch(args, keyboard: Katana): def main(): args = build_parser().parse_args() - keyboard = Katana() + keyboard = open_katana() try: dispatch(args, keyboard) finally: diff --git a/katana.yaml.example b/katana.yaml.example index 7f16ffc..e30293e 100644 --- a/katana.yaml.example +++ b/katana.yaml.example @@ -61,7 +61,7 @@ lighting: # direction: north-south # направление: north-south|south-north|east-west|west-east # flag: null # переопределение байта flag (обычно не нужно) # ВНИМАНИЕ: у breathing (режим 7) цвет игнорируется прошивкой — цикл зашит - # (см. docs/COLOR-MODES.md, сноска ¹). Для моноцвета берите static/fade. + # (см. docs/color-modes.md, сноска ¹). Для моноцвета берите static/fade. # --- сырой payload (команда raw, для экспериментов) ---------------------------- # raw: "80 00 00 00 00 00 00 00 00 0f 00 00 00 00 aa 55" diff --git a/analyze_pcap.py b/misc/analyze_pcap.py similarity index 100% rename from analyze_pcap.py rename to misc/analyze_pcap.py diff --git a/misc/iokit_probe.py b/misc/iokit_probe.py new file mode 100644 index 0000000..8171260 --- /dev/null +++ b/misc/iokit_probe.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Диагностика открытия HID-устройства через IOKit напрямую (ctypes). + +Берёт путь устройства из hidapi (DevSrvsID: = 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() diff --git a/keymap.py b/misc/keymap.py similarity index 99% rename from keymap.py rename to misc/keymap.py index 7522bad..ed26842 100755 --- a/keymap.py +++ b/misc/keymap.py @@ -3,7 +3,7 @@ Построена экспериментально: группы индексов красились контрастными цветами, пользователь сообщал, какие клавиши загорелись (методика и открытые вопросы — -в docs/PROTOCOL.md). +в docs/protocol.md). Имена клавиш — позиции US-раскладки. """ diff --git a/probe.py b/misc/probe.py similarity index 98% rename from probe.py rename to misc/probe.py index a19bcd1..7e6dc18 100755 --- a/probe.py +++ b/misc/probe.py @@ -5,7 +5,7 @@ HIDIOCGFEATURE(len) = _IOWR('H', 0x07, len) — размер кодируетс Для состояния клавиатуры зонд безопасен: только GET_FEATURE-запросы, ничего не записывается. -Использование: probe.py [hidrawN] +Использование: misc/probe.py [hidrawN] (по умолчанию — автоопределение всех hidraw-узлов клавиатуры) """ import array diff --git a/tests/test_parsers.py b/tests/test_parsers.py index cdf371f..e22cb76 100755 --- a/tests/test_parsers.py +++ b/tests/test_parsers.py @@ -15,7 +15,7 @@ import sys sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -import keymap +import misc.keymap as keymap from katana import (ALPHA_KEYS, ARROW_KEYS, BIND_COUNT, DIGITS_KEYS, KEYS_COUNT, MODES, NUMPAD_KEYS, PUNCT_KEYS, ROW_KEYS, Katana, _hid_ioc, _hid_iocgfeature, _hid_iocsfeature, build_payload, diff --git a/tests/test_transport.py b/tests/test_transport.py new file mode 100644 index 0000000..efd5862 --- /dev/null +++ b/tests/test_transport.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +"""Офлайн-тесты выбора транспорта katana.py (без железа). + +Запуск: python3 tests/test_transport.py (или pytest tests/test_transport.py). + +Покрывает: фабрику open_katana (платформенный выбор Katana/KatanaMacOS), +поиск устройства _mac_find_service (выбор boot keyboard) и формат обмена +KatanaMacOS._set/_get через IOKit (feature-репорты, 64 байта, rid=0). +IOKit подменяется фейком в katana._mac_load_iokit — реальный IOKit есть +только на macOS, поэтому на Linux тесты тоже проходят. +""" +import os +import sys +import types + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import katana +from katana import Katana, KatanaMacOS, open_katana + + +class FakeIOKit: + """Подменный IOKit: журнал вызовов, зашитые свойства и ACK прошивки. + + Имитирует только то, что использует KatanaMacOS: + перечисление (одна клавиатура: boot keyboard + consumer-интерфейс), + IOHIDDeviceSetReport/GetReport (feature-репорты rid=0). + """ + + def __init__(self): + self.sent, self.get_count = [], 0 + self.ack = bytes([0x04, 0x18, 0x00, 0x01]) + bytes(60) + # свойства двух интерфейсов клавиатуры (и одно чужое устройство) + self.props = { + 0x101: {"VendorID": 0x0C45, "ProductID": 0x8006, + "PrimaryUsagePage": 1, "PrimaryUsage": 6}, # iface0 + 0x102: {"VendorID": 0x0C45, "ProductID": 0x8006, + "PrimaryUsagePage": 12, "PrimaryUsage": 1}, # iface1 + 0x103: {"VendorID": 0x1234, "ProductID": 0x5678, + "PrimaryUsagePage": 1, "PrimaryUsage": 6}, # чужое + } + self.released = [] + + # -- перечисление -- + + def IOServiceGetMatchingServices(self, port, matcher, it_ref): + # it_ref — ctypes.byref(c_uint); храним итератор отдельно + self._it = iter(list(self.props)) + return 0 + + def IOServiceMatching(self, name): + return object() # фиктивный словарь соответствия + + def IOIteratorNext(self, it): + return next(self._it, 0) + + def IOObjectRelease(self, entry): + self.released.append(entry) + + def IOHIDDeviceCreate(self, port, entry): + return f"dev{entry:#x}" if entry in self.props else None + + def IOHIDDeviceGetProperty(self, dev, key): + entry = int(dev[3:], 16) + return self.props[entry].get(key.value.decode()) + + # -- feature-репорты -- + + def IOHIDDeviceSetReport(self, dev, rtype, rid, buf, length): + self.sent.append((rtype, rid, bytes(buf[:length]))) + return 0 + + def IOHIDDeviceGetReport(self, dev, rtype, rid, buf, n_ref): + # n_ref приходит как ctypes.byref(n) — распаковываем через _obj + n = min(n_ref._obj.value, len(self.ack)) + for i in range(n): + buf[i] = self.ack[i] + n_ref._obj.value = n + self.get_count += 1 + return 0 + + def IOHIDDeviceOpen(self, dev, options): + return 0 + + def IOHIDDeviceClose(self, dev, options): + return 0 + + +class FakeCF: + """Подменный CoreFoundation: строки-ключи как есть, CFNumber → int.""" + + def CFStringCreateWithCString(self, alloc, s, encoding): + return types.SimpleNamespace(value=s) + + def CFNumberGetValue(self, num, ntype, out_ref): + out_ref._obj.value = int(num) + return True + + def CFRelease(self, obj): + pass + + +def make_macos_katana(): + """KatanaMacOS с подмененными IOKit/CF; возвращает (k, fake_iokit, restore).""" + fake_iokit, fake_cf = FakeIOKit(), FakeCF() + saved = (katana._mac_load_iokit, katana._mac_find_service) + + def fake_load(): + return fake_iokit, fake_cf + + def fake_find(iokit, cf): + return 0x101 # boot keyboard (iface0) + + katana._mac_load_iokit = fake_load + katana._mac_find_service = fake_find + k = KatanaMacOS() + + def restore(): + katana._mac_load_iokit, katana._mac_find_service = saved + return k, fake_iokit, restore + + +def test_open_katana_darwin(): + """open_katana на darwin возвращает KatanaMacOS (IOKit-транспорт).""" + orig = sys.platform + sys.platform = "darwin" + try: + k, fake, restore = make_macos_katana() + try: + got = open_katana() + assert isinstance(got, KatanaMacOS) + got.close() + finally: + restore() + finally: + sys.platform = orig + + +def test_mac_find_service_boot_keyboard(): + """_mac_find_service: выбирает boot keyboard (page 1, usage 6) клавиатуры.""" + fake_iokit, fake_cf = FakeIOKit(), FakeCF() + assert katana._mac_find_service(fake_iokit, fake_cf) == 0x101 + + +def test_mac_find_service_not_found(): + """_mac_find_service: без boot keyboard клавиатуры — SystemExit.""" + fake_iokit, fake_cf = FakeIOKit(), FakeCF() + del fake_iokit.props[0x101] # убрать клавиатурный интерфейс + try: + katana._mac_find_service(fake_iokit, fake_cf) + except SystemExit as e: + assert "0c45:8006" in str(e), str(e) + else: + raise AssertionError("must fail without boot keyboard") + + +def test_macos_set_get_format(): + """KatanaMacOS._set/_get: feature-репорт rid=0, payload 64 байта целиком.""" + k, fake, restore = make_macos_katana() + try: + payload = bytes([0x04, 0x18]) + bytes(62) + k._set(payload) + assert fake.sent == [(KatanaMacOS.REPORT_FEATURE, 0, payload)] + assert k._get() == fake.ack + assert fake.get_count == 1 + k.close() + finally: + restore() + + +def test_macos_cmd_uses_inherited_logic(): + """KatanaMacOS._cmd: унаследованная логика команд работает поверх IOKit.""" + k, fake, restore = make_macos_katana() + try: + ack = k._cmd(0x18) + assert ack[:4] == b"\x04\x18\x00\x01" + # _cmd шлёт 64-байтную команду 04 как feature-репорт rid=0 + rtype, rid, buf = fake.sent[0] + assert rtype == KatanaMacOS.REPORT_FEATURE and rid == 0 + assert len(buf) == 64 and buf[0:3] == b"\x04\x18\x00" + k.close() + finally: + restore() + + +def test_open_katana_linux_class(): + """На не-darwin платформе фабрика возвращает hidraw-класс Katana.""" + orig = sys.platform + sys.platform = "linux" + try: + # Katana.__init__ открывает hidraw-узел — на macOS его нет, + # поэтому проверяем только тип: SystemExit от find_hidraw = ветка верная + try: + open_katana() + except SystemExit as e: + assert "hidraw" in str(e), str(e) + except OSError: + pass # /dev/hidraw* существует, но клавиатуры нет — тоже ок + else: + raise AssertionError("linux branch must try hidraw, not IOKit") + finally: + sys.platform = orig + + +def run_all(): + tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")] + failed = 0 + for t in tests: + try: + t() + print(f"ok {t.__name__}") + except AssertionError as e: + failed += 1 + print(f"FAIL {t.__name__}: {e}") + if failed: + raise SystemExit(f"{failed} test(s) failed") + print(f"all {len(tests)} transport tests passed") + + +if __name__ == "__main__": + run_all()