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()