#!/usr/bin/env python3 """Управление RGB-подсветкой GANSS ARDOR_Katana (0c45:8006) из Linux. Полная документация протокола — docs/protocol.md, карта «LED-индекс → клавиша» — keymap.py, справочник команд — docs/CLI.md. Краткая справка (подробности в protocol.md): - Транспорт: HID feature-репорты rid=0 через ioctl к /dev/hidrawN (драйвер usbhid не отцепляется, печать не прерывается). НИКОГДА не использовать pyusb с detach_kernel_driver. - Транзакция: 04 18 begin → 04 13+01 data-begin → payload → 04 02 commit (байты 4–5 ACK = LE16 сумма payload) → 04 f0 save. Всегда закрывать save, иначе прошивка клинится (лечится командой reset). - ACK байт [3]: 01 = ok; 00/ff = отказ; begin ff = «сессия уже открыта» (ок). - Lighting payload: [0] режим 1..19 или 0x80=custom, [1..3] RGB, [8] flag, [9] яркость 1..15, [10] скорость 2..15 (для статики форсируется 0x0a), [11] подрежим (моно/радуга, направление), [14..15] aa 55. Off = нулевой payload + aa55. - Per-key таблица: сессия 04 23+09, 9 пакетов = 143×[idx,r,g,b] + 0000 + aa55; отображается режимом 0x80. Живой кадр читается через 04 f5 (без data-begin!): SET f5 → 9 GET-пакетов → commit → save. - Переназначения: сессия 04 11+09, 143×4 байта; мышь [01,01,код,00], шорткат [02, маска_модификаторов, HID-код, 00] (функции редактора — EDITOR_ACTIONS, горячие клавиши — HOTKEY_KEYS/HOTKEY_MODIFIERS), мультимедиа [03, usage u16 LE, 00] (Consumer Page — MULTIMEDIA_ACTIONS), макрос [06, индекс, режим, счётчик]. - Макросы: содержимое — сессия 04 19 → 04 15+пакеты (без save!), привязка — remap --key KEY=macro. Примеры: ./katana.py mode 1 --color ff00ff # статика, маджента ./katana.py mode 5 --color 00ff00 --speed 3 ./katana.py off # выключить подсветку ./katana.py paint --all ff0000 # вся клавиатура красным ./katana.py paint --black --key Tab=00ff00 # только Tab зелёным ./katana.py paint --key a=ff0000 --key s=00ff00 --key d=0000ff ./katana.py paint --black --key 0x25=00ff00 # hex-индекс тоже работает ./katana.py keys # живой per-key кадр ./katana.py remap --key caps=lmb # Caps → левая кнопка мыши ./katana.py remap --key caps=ctrl+c # Caps → горячая клавиша Ctrl+C ./katana.py remap --key menu=meta+e # Menu → горячая клавиша Win+E ./katana.py macro set 0 -- a@126 s@101 -a # макрос 0: A, S, отпустить A ./katana.py macro set 1 --xml test.xml # макрос 1 из вендорского XML ./katana.py remap --key fn=macro0:5 # Fn → макрос 0 пять раз ./katana.py reset # разблокировать прошивку без переподключения """ import argparse import ctypes import ctypes.util import fcntl import glob import os import re import struct import sys import time # ---- константы устройства и протокола ------------------------------------- VID, PID = 0x0C45, 0x8006 # USB ID клавиатуры (Sonix SN32) DELAY = 0.04 # пауза между USB-запросами (вендорский тайминг) # Опкоды команд канала 04 xx OP_BEGIN = 0x18 # открыть сессию OP_DATA = 0x13 # data-begin (начало блока данных) OP_COMMIT = 0x02 # зафиксировать (в ACK — контрольная сумма) OP_SAVE = 0xF0 # записать во флеш (обязателен, иначе клин прошивки) OP_KEYS_WRITE = 0x23 # запись per-key таблицы OP_KEYS_READ = 0xF5 # чтение живого per-key кадра OP_BIND_WRITE = 0x11 # запись таблицы переназначений клавиш OP_MACRO_BEGIN = 0x19 # begin сессии содержимого макросов (вместо 04 18!) OP_MACRO_DATA = 0x15 # data-begin блока макросов (байт [8] = число пакетов) KEYS_BLOCK_PARAM = 0x09 # параметр блока в командах 23/f5/11 KEYS_COUNT = 143 # записываемых слотов 0x00..0x8e KEYS_BLOB_LEN = 576 # 143×4 + 2 pad + 2 magic (aa55) BIND_COUNT = 143 # слотов таблицы переназначений (как у per-key) BIND_BLOB_LEN = 576 # 143×4 + 2 pad + 2 magic (aa55) MODE_CUSTOM = 0x80 # lighting-режим, отображающий per-key таблицу MACRO_HEADER_LEN = 400 # заголовок блоба макросов: 100 u32-смещений (0 = пусто) MACRO_MAX_BLOB = 896 # 14 пакетов × 64 байта — максимум из дампа macros-create # Коды кнопок мыши для переназначения (байт [2] записи слота). # Снято по дампу keybind-mouse-buttons: значения — степени двойки, # поэтому вероятна битовая маска (несколько кнопок одновременно), # но комбинированные записи железом не проверялись. MOUSE_ACTIONS = { "lmb": 0x01, "mouse1": 0x01, # левая кнопка "rmb": 0x02, "mouse2": 0x02, # правая кнопка "mmb": 0x04, "mouse3": 0x04, # средняя кнопка "back": 0x08, "mouseback": 0x08, # «назад» (боковая) "forward": 0x10, "mouseforward": 0x10, # «вперёд» (боковая) } # Функции текстового редактора для переназначения (запись [02, 01, код, 00]). # Снято по дампу keybind-editor: вендорская утилита вешает на Caps Lock # шорткаты Ctrl+<буква>, код в байте [2] — HID usage ID буквы, порядок # транзакций в дампе совпадает с порядком списка в UI. # Байт [1] = 01 — модификатор (наблюдался только Ctrl); другие модификаторы # и произвольные шорткаты не исследованы (см. protocol.md). EDITOR_ACTIONS = { "open": 0x12, # Открыть (Ctrl+O) "new": 0x11, # Создать (Ctrl+N) "undo": 0x1D, # Отмена (Ctrl+Z) "save": 0x16, # Сохранить (Ctrl+S) "copy": 0x06, # Копировать (Ctrl+C) "cut": 0x1B, # Вырезать (Ctrl+X) "paste": 0x19, # Вставить (Ctrl+V) "find": 0x09, # Найти (Ctrl+F) "selectall": 0x04, # Выбрать всё (Ctrl+A) } # Мультимедиа- и веб-действия для переназначения (запись [03, usage_lo, # usage_hi, 00]). Снято по дампу keybind-multimedia: вендорская утилита # перебирает 18 функций списка на Caps Lock, байты [1..2] — 16-битный LE # usage ID из HID Consumer Page (0x0C), сверен с HID Usage Tables. # Порядок значений словаря совпадает с порядком функций в UI вендора. MULTIMEDIA_ACTIONS = { "player": 0x0183, # Плеер (AL Consumer Control Config) "play": 0x00CD, "playpause": 0x00CD, # Воспроизведение/пауза "stop": 0x00B7, # Стоп "prev": 0x00B6, "prevsong": 0x00B6, # Предыдущая песня "next": 0x00B5, "nextsong": 0x00B5, # Следующая песня "volup": 0x00E9, # Громкость + "voldown": 0x00EA, # Громкость - "mute": 0x00E2, # Отключить звук "home": 0x0223, "homepage": 0x0223, # Домашняя страница "refresh": 0x0227, "webrefresh": 0x0227, # Веб: обновить "webstop": 0x0226, # Веб: остановить "webback": 0x0224, # Веб: назад "webforward": 0x0225, # Веб: вперед "favorites": 0x022A, "webfavorites": 0x022A, # Веб: избранное "websearch": 0x0221, "search": 0x0221, # Веб: поиск "mycomputer": 0x0194, "computer": 0x0194, # Мой компьютер "calculator": 0x0192, "calc": 0x0192, # Калькулятор "email": 0x018A, # Электронная почта } # Эмуляция горячих клавиш для переназначения (запись [02, модификаторы, # HID-код, 00]). Снято по дампу keybind-hotkeys: вендорская утилита вешает # на Caps Lock шорткаты A, Shift+B, Ctrl+C, Alt+D, Meta+E, Esc, F1, Num1, # Fn и «Мой компьютер» — первые девять имеют тип 02, как шорткаты редактора, # «Мой компьютер» — мультимедиа-действие (см. MULTIMEDIA_ACTIONS). # Байт [1] — БИТОВАЯ МАСКА модификаторов (у редакторских шорткатов был # замечен только Ctrl=01, дамп keybind-hotkeys подтверждает остальные): # 01 = Ctrl, 02 = Shift, 04 = Alt, 08 = Meta (Win). Код в байте [2] — # HID usage ID клавиши (Keyboard/Keypad page 0x07). HOTKEY_MODIFIERS = { "ctrl": 0x01, "control": 0x01, # Ctrl "shift": 0x02, # Shift "alt": 0x04, # Alt "meta": 0x08, "win": 0x08, "super": 0x08, # Meta (Win) } # Коды клавиш горячих клавиш (байт [2] записи, HID usage ID). # Буквы, цифры, F-клавиши и Esc сверены с дампом keybind-hotkeys # (A=04, B=05, C=06, D=07, E=08, Esc=29, F1=3A, Num1=59). # Fn = 0xAF — СОБСТВЕННЫЙ код прошивки: в HID Usage Tables такого usage нет, # но вендор шлёт именно его (дамп keybind-hotkeys, 9-я транзакция). HOTKEY_KEYS = { **{c: 0x04 + i for i, c in enumerate("abcdefghijklmnopqrstuvwxyz")}, **{c: 0x1E + i for i, c in enumerate("1234567890")}, **{f"f{i}": 0x39 + i for i in range(1, 13)}, # F1=0x3A .. F12=0x45 "esc": 0x29, "tab": 0x2B, "enter": 0x28, "backspace": 0x2A, "caps": 0x39, "space": 0x2C, **{f"num{i}": 0x58 + i for i in range(1, 10)}, # Num1=0x59 .. Num9=0x61 "num0": 0x62, "fn": 0xAF, # собственный код прошивки (не HID) } # ---- макросы ----------------------------------------------------------------- # Тип события (байт [3] восьмибайтового события, дамп macros-create). MACRO_KEY_DOWN = 0xB0 # клавиша нажата MACRO_KEY_UP = 0x30 # клавиша отпущена MACRO_MOUSE_DOWN = 0x90 # кнопка мыши нажата MACRO_MOUSE_UP = 0x10 # кнопка мыши отпущена # Коды клавиш макросов (байт [2] события). Для букв, цифр, F-клавиш и # модификаторов совпадают с HID usage ID (сверено с дампом macros-create: # A=04, S=16, Q=14, LCtrl=E0, LAlt=E2, LWin=E3). Стрелки и медиа — # СОБСТВЕННЫЕ коды прошивки, не HID: Left=5C, Right=5E, VolUp=05, # VolDown=06 (сверено); Up/Down — предположение 5D/5F, железом не проверены. MACRO_KEYS = { **{c: 0x04 + i for i, c in enumerate("abcdefghijklmnopqrstuvwxyz")}, **{c: 0x1E + i for i, c in enumerate("1234567890")}, **{f"f{i}": 0x39 + i for i in range(1, 13)}, # F1=0x3A .. F12=0x45 "esc": 0x29, "tab": 0x2B, "enter": 0x28, "backspace": 0x2A, "caps": 0x39, "space": 0x2C, "lctrl": 0xE0, "lshift": 0xE1, "lalt": 0xE2, "lwin": 0xE3, "rctrl": 0xE4, "rshift": 0xE5, "ralt": 0xE6, "rwin": 0xE7, # собственные коды прошивки (не HID!) "left": 0x5C, "up": 0x5D, "right": 0x5E, "down": 0x5F, "volup": 0x05, "voldown": 0x06, } # VK-коды Windows → коды макросов (для импорта вендорского XML). # Сверено по дампу macros-create + macros-create-test.xml (VK 81→14 = Q, # 162→E0 = LCtrl, 174→06 = VolDown, ...); остальные — стандартный HID, # железом не проверены. VK_TO_MACRO = { 8: 0x2A, 9: 0x2B, 13: 0x28, 20: 0x39, 27: 0x29, 32: 0x2C, **{vk: 0x04 + i for i, vk in enumerate(range(65, 91))}, # A..Z **{vk: 0x1E + i for i, vk in enumerate(range(49, 58))}, # '1'..'9' 48: 0x27, # '0' **{vk: 0x3A + i for i, vk in enumerate(range(112, 124))}, # F1..F12 37: 0x5C, 38: 0x5D, 39: 0x5E, 40: 0x5F, # стрелки 91: 0xE3, 92: 0xE7, # LWin/RWin 160: 0xE1, 161: 0xE5, 162: 0xE0, 163: 0xE4, # Shift/Ctrl 164: 0xE2, 165: 0xE6, # Alt 174: 0x06, 175: 0x05, # VolDown/VolUp } # Кнопки мыши в XML: value 1 = ЛКМ, 2 = СКМ, 3 = ПКМ (порядок вендора!). VK_TO_MOUSE = {1: 0x01, 2: 0x04, 3: 0x02} # Обратные таблицы: код → имя / код → VK (для показа и экспорта XML). # Коды 0x05/0x06 двусмысленны: это VolUp/VolDown прошивки и одновременно # HID-коды букв B/C (коллизия кодового пространства, см. protocol.md). # При показе и экспорте предпочитаем медиа-имена, как в вендорском XML. MACRO_CODE_NAMES = {0x05: "volup", 0x06: "voldown", 0x5C: "left", 0x5D: "up", 0x5E: "right", 0x5F: "down"} for _name, _code in MACRO_KEYS.items(): # MACRO_KEYS: имя → код MACRO_CODE_NAMES.setdefault(_code, _name) # каноническое имя кнопки = первый алиас из MOUSE_ACTIONS (lmb, rmb, ...) MACRO_MOUSE_NAMES = {} for _name, _code in MOUSE_ACTIONS.items(): MACRO_MOUSE_NAMES.setdefault(_code, _name) MACRO_TO_VK = {code: vk for vk, code in VK_TO_MACRO.items()} MACRO_TO_VK_MOUSE = {code: vk for vk, code in VK_TO_MOUSE.items()} MODE_MIN, MODE_MAX = 1, 19 # номер режима = байт [0] payload BRIGHT_MIN, BRIGHT_MAX = 0x01, 0x0F # яркость SPEED_MIN, SPEED_MAX = 0x02, 0x0F # скорость анимации (диапазон слайдера) SPEED_STATIC = 0x0A # вендор всегда шлёт это для mode 1 FLAG_DEFAULTS = {1: 0x00, 4: 0x00} # для остальных режимов flag = 0x01 # ---- таблица режимов и алиасы ---------------------------------------------- # Номер режима → (алиас, описание). Порядок совпадает со списком вендорской # утилиты (docs/color-modes.md). «моно/спектр» — режим рисует одиночный --color # или полный спектр независимо от него. MODES = { 1: ("static", "Постоянный свет всех клавиш (моно/спектр)"), 2: ("keypress", "Подсветка нажатых: всё выключено, горят нажатые"), 3: ("fade", "Затухание нажатых: всё включено, гаснут нажатые"), 4: ("star", "Звёздный: случайные вспышки после полного затухания"), 5: ("snow", "Снежный: случайные вспышки, больше соседних клавиш"), 6: ("flower", "Цветочный: плавные цвета на каждой клавише (спектр)"), 7: ("breathing", "Дыхание: плавное свечение и затухание всех клавиш"), 8: ("spectrum", "Полный спектр: плавные цвета на всех клавишах"), 9: ("ripple", "Круги: плавные цвета кругами из центра к краям"), 10: ("waterfall", "Водопад: спектр по рядам (направление вверх/вниз)"), 11: ("flow", "Течение: косые волны в одну сторону (влево/вправо)"), 12: ("rotate", "Вращение: волна по кругу вокруг центра (влево/вправо)"), 13: ("h-edge", "Горизонтальная грань: волна по ряду от нажатой клавиши"), 14: ("v-edge", "Вертикальная грань: косая волна от нажатой клавиши"), 15: ("splash", "Рябь по воде: круги от нажатой клавиши"), 16: ("marquee", "Бегущая строка по клавишам (влево/вправо)"), 17: ("mountains", "Горы: плавные углы из середины в стороны"), 18: ("wind", "Ветер: косые волны с затуханием (влево/вправо)"), 19: ("shuttle", "Шаттл: бегущие строки по чётным/нечётным рядам"), } MODE_ALIASES = {name: num for num, (name, _) in MODES.items()} MODE_ALIASES["off"] = 0 # пункт 20 списка вендора: нулевой payload # Имена клавиш нумпада (позиции из keymap.py) для --numpad NUMPAD_KEYS = ("numlock", "num/", "num*", "num-", "num7", "num8", "num9", "num4", "num5", "num6", "num+", "num1", "num2", "num3", "num0", "num.", "numenter") # Буквенные клавиши (A–Z) для --alpha ALPHA_KEYS = ("q", "w", "e", "r", "t", "y", "u", "i", "o", "p", "a", "s", "d", "f", "g", "h", "j", "k", "l", "z", "x", "c", "v", "b", "n", "m") # Знаковые клавиши основного блока (правее букв, перед Enter/RShift) для --punct PUNCT_KEYS = ("[", "]", ";", "'", chr(92), ",", ".", "/") # Пробел — отдельная широкая клавиша, добавляется к --punct SPACE_KEY = "space" # Цифровой ряд (тильда, 1–0, минус, равно) для --digits DIGITS_KEYS = ("`", "1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "-", "=") # Стрелки для --arrows ARROW_KEYS = ("arrowleft", "arrowdown", "arrowup", "arrowright") # Горизонтальные ряды полной клавиатуры (слева направо, включая нумпад) # для --row1..--row6. Высокие клавиши нумпада (Num+, NumEnter) отнесены # к ряду, где они начинаются физически. ROW_KEYS = { 1: ("esc", "f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8", "f9", "f10", "f11", "f12", "prtsc", "scrlk", "pause"), 2: ("`", "1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "-", "=", "backspace", "insert", "home", "pgup", "numlock", "num/", "num*", "num-"), 3: ("tab", "q", "w", "e", "r", "t", "y", "u", "i", "o", "p", "[", "]", chr(92), "delete", "end", "pgdown", "num7", "num8", "num9", "num+"), 4: ("caps", "a", "s", "d", "f", "g", "h", "j", "k", "l", ";", "'", "enter", "num4", "num5", "num6"), 5: ("lshift", "z", "x", "c", "v", "b", "n", "m", ",", ".", "/", "rshift", "arrowup", "num1", "num2", "num3", "numenter"), 6: ("lctrl", "lwin", "lalt", "space", "ralt", "fn", "menu", "rctrl", "arrowleft", "arrowdown", "arrowright", "num0", "num."), } # Символы, не являющиеся именами в keymap.py, но однозначно указывающие # на клавишу (shifted-варианты): '~' стоит на клавише '`(~)'. # Подмешиваются в кэш при первом обращении. _KEY_EXTRA_SYMBOLS = { "~": "`", # тильда — shifted-вариант клавиши backquote } # Семантика байта [11] payload (снято по захватам color-toggle-*): # mode 1 (static): 00 = моно (--color), 01 = радужный спектр # mode 10 (waterfall): 02 = north→south, 03 = south→north # mode 16 (marquee): 01 = east→west, 00 = west→east # Для flow/rotate/wind значения пока не сняты. DIRECTION_BYTES = { 10: ("north-south", 0x02, "south-north", 0x03), 16: ("east-west", 0x01, "west-east", 0x00), } # Именованные цвета: полный набор CSS Color Module Level 4 (X11 base colors) # плюс вендорские нестандартные (magenta, violet, lime, orange, grey). # Принимаются везде, где парсится цвет (hex RRGGBB тоже работает). COLOR_ALIASES = { # розовые "pink": "ffc0cb", "lightpink": "ffb6c1", "hotpink": "ff69b4", "deeppink": "ff1493", "palevioletred": "db7093", "mediumvioletred": "c71585", "darkmagenta": "8b008b", "lavenderblush": "fff0f5", # красные "lightsalmon": "ffa07a", "salmon": "fa8072", "darksalmon": "e9967a", "lightcoral": "f08080", "indianred": "cd5c5c", "crimson": "dc143c", "firebrick": "b22222", "darkred": "8b0000", "red": "ff0000", # оранжевые "orangered": "ff4500", "tomato": "ff6347", "coral": "ff7f50", "darkorange": "ff8c00", "orange": "ffa500", # жёлтые "yellow": "ffff00", "lightyellow": "ffffe0", "lemonchiffon": "fffacd", "lightgoldenrodyellow": "fafad2", "papayawhip": "ffefd5", "moccasin": "ffe4b5", "peachpuff": "ffdab9", "palegoldenrod": "eee8aa", "khaki": "f0e68c", "darkkhaki": "bdb76b", "gold": "ffd700", # коричневые "cornsilk": "fff8dc", "blanchedalmond": "ffebcd", "bisque": "ffe4c4", "navajowhite": "ffdead", "wheat": "f5deb3", "burlywood": "deb887", "tan": "d2b48c", "rosybrown": "bc8f8f", "sandybrown": "f4a460", "goldenrod": "daa520", "darkgoldenrod": "b8860b", "peru": "cd853f", "chocolate": "d2691e", "saddlebrown": "8b4513", "sienna": "a0522d", "brown": "a52a2a", "maroon": "800000", # зелёные "darkolivegreen": "556b2f", "olive": "808000", "olivedrab": "6b8e23", "yellowgreen": "9acd32", "limegreen": "32cd32", "lime": "00ff00", "lawngreen": "7cfc00", "chartreuse": "7fff00", "greenyellow": "adff2f", "springgreen": "00ff7f", "mediumspringgreen": "00fa9a", "lightgreen": "90ee90", "palegreen": "98fb98", "darkseagreen": "8fbc8f", "mediumseagreen": "3cb371", "seagreen": "2e8b57", "forestgreen": "228b22", "green": "008000", "darkgreen": "006400", # циановые "mediumaquamarine": "66cdaa", "aqua": "00ffff", "cyan": "00ffff", "lightcyan": "e0ffff", "paleturquoise": "afeeee", "aquamarine": "7fffd4", "turquoise": "40e0d0", "mediumturquoise": "48d1cc", "darkturquoise": "00ced1", "lightseagreen": "20b2aa", "cadetblue": "5f9ea0", "darkcyan": "008b8b", "teal": "008080", # синие "lightsteelblue": "b0c4de", "powderblue": "b0e0e6", "lightblue": "add8e6", "skyblue": "87ceeb", "lightskyblue": "87cefa", "deepskyblue": "00bfff", "dodgerblue": "1e90ff", "cornflowerblue": "6495ed", "steelblue": "4682b4", "royalblue": "4169e1", "blue": "0000ff", "mediumblue": "0000cd", "darkblue": "00008b", "navy": "000080", "midnightblue": "191970", # сине-фиолетовые "lavender": "e6e6fa", "ghostwhite": "f8f8ff", "aliceblue": "f0f8ff", "mintcream": "f5fffa", "honeydew": "f0fff0", "azure": "f0ffff", "slateblue": "6a5acd", "mediumslateblue": "7b68ee", "mediumpurple": "9370db", "blueviolet": "8a2be2", "darkslateblue": "483d8b", "rebeccapurple": "663399", # фиолетовые "thistle": "d8bfd8", "plum": "dda0dd", "violet": "ee82ee", "orchid": "da70d6", "fuchsia": "ff00ff", "magenta": "ff00ff", "mediumorchid": "ba55d3", "darkorchid": "9932cc", "darkviolet": "9400d3", "purple": "800080", "indigo": "4b0082", # белые "white": "ffffff", "snow": "fffafa", "ivory": "fffff0", "floralwhite": "fffaf0", "oldlace": "fdf5e6", "linen": "faf0e6", "antiquewhite": "faebd7", "seashell": "fff5ee", "beige": "f5f5dc", "whitesmoke": "f5f5f5", # серые и чёрный "gainsboro": "dcdcdc", "lightgray": "d3d3d3", "lightgrey": "d3d3d3", "silver": "c0c0c0", "darkgray": "a9a9a9", "darkgrey": "a9a9a9", "gray": "808080", "grey": "808080", "dimgray": "696969", "dimgrey": "696969", "lightslategray": "778899", "lightslategrey": "778899", "slategray": "708090", "slategrey": "708090", "darkslategray": "2f4f4f", "darkslategrey": "2f4f4f", "black": "000000", } # нестандартные имена, не входящие в CSS, но привычные по X11/вендорским # утилитам: magenta=fuchsia, violet, lime (ярче css limegreen) COLOR_ALIASES.update({ "lime": "00ff00", # в CSS lime = 00ff00 — совпадает, оставляем "violet": "ee82ee", "orange": "ffa500", }) # ---- парсеры аргументов ------------------------------------------------------ def parse_color(s: str) -> tuple[int, int, int]: """'ff0000' / 'red' / '#ff0000' → (r, g, b).""" s = s.lstrip("#").lower() s = COLOR_ALIASES.get(s, s) if len(s) != 6: raise argparse.ArgumentTypeError( f"color must be RRGGBB hex or one of: {', '.join(sorted(COLOR_ALIASES))}") return int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16) def parse_mode(s: str) -> int: """Номер режима, алиас ('static', 'breathing', ...) или 'off'.""" try: return int(s, 0) except ValueError: pass mode = MODE_ALIASES.get(s.strip().lower()) if mode is None: known = ", ".join(f"{n}={MODES[n][0]}" for n in sorted(MODES)) raise argparse.ArgumentTypeError(f"unknown mode {s!r}; known: {known}, off") return mode def check_range(val: int, lo: int, hi: int, name: str) -> int: """Проверка диапазона с понятным сообщением об ошибке.""" if not lo <= val <= hi: raise SystemExit(f"{name} must be {lo}..{hi}, got {val}") return val # ---- сопоставление имён клавиш LED-индексам ---------------------------------- _KEY_ALIASES = None # кэш; ленивая инициализация, чтобы работать без keymap.py # Символы, не являющиеся именами в keymap.py, но однозначно указывающие # на клавишу (shifted-варианты): '~' стоит на клавише '`(~)'. # Подмешиваются в кэш при первом обращении. _KEY_EXTRA_SYMBOLS = { "~": "`", # тильда — shifted-вариант клавиши backquote } def resolve_key(name: str): """'Tab', 'a', 'num7', 'tilde', '0x25' → LED-индекс или None. Принимает канонические имена из keymap.py, символы ('[', '~', ';'), синонимы из KEY_SYMBOL_ALIASES ('lbracket', 'minus', ...) и hex-индексы. """ global _KEY_ALIASES # hex-индекс передаётся напрямую; голые цифры ('1', '0') — это ИМЕНА # клавиш цифрового ряда, а не индексы, поэтому int() без префикса 0x # здесь не применяется if name.lower().startswith("0x"): try: return int(name, 16) except ValueError: return None if _KEY_ALIASES is None: try: from misc.keymap import KEYMAP, KEY_SYMBOL_ALIASES except ImportError: return None _KEY_ALIASES = {} for idx, label in KEYMAP.items(): # "num.(del)" → базовое имя "num." + полный вариант base = label.split("(")[0].strip().lower() _KEY_ALIASES.setdefault(base, idx) _KEY_ALIASES.setdefault(label.lower(), idx) # дополнительные символы ('~') указывают на каноническое имя: # раскрываем его в индекс сразу, в кэше хранятся только индексы for symbol, canonical in _KEY_EXTRA_SYMBOLS.items(): if canonical in _KEY_ALIASES: _KEY_ALIASES.setdefault(symbol, _KEY_ALIASES[canonical]) # синонимы из keymap.py тоже сразу раскрываются в индексы for alias, canonical in KEY_SYMBOL_ALIASES.items(): if canonical in _KEY_ALIASES: _KEY_ALIASES.setdefault(alias, _KEY_ALIASES[canonical]) key = name.strip().lower() return _KEY_ALIASES.get(key) # ---- формирование payload ------------------------------------------------------ def build_payload(mode: int, rgb: tuple[int, int, int], flag: int = 1, brightness: int = 0x0F, speed: int = 0x0A, submode: int = 0x00) -> bytes: """64-байтный lighting payload (раскладка байтов — в докстринге модуля).""" r, g, b = rgb buf = bytearray(64) buf[0] = mode buf[1], buf[2], buf[3] = r, g, b buf[8] = flag buf[9] = brightness buf[10] = speed buf[11] = submode # моно/радуга (mode 1), направление анимации buf[14], buf[15] = 0xAA, 0x55 return bytes(buf) # ---- hidraw-транспорт ----------------------------------------------------------- def _hid_ioc(nr: int, size: int) -> int: """Номер ioctl _IOWR('H', nr, size): размер кодируется в номере.""" return (3 << 30) | (size << 16) | (0x48 << 8) | nr def _hid_iocsfeature(size: int) -> int: # запись feature-репорта return _hid_ioc(0x06, size) def _hid_iocgfeature(size: int) -> int: # чтение feature-репорта return _hid_ioc(0x07, size) def find_hidraw() -> str: """Путь /dev/hidrawN для интерфейса :1.0 клавиатуры (конфиг-канал).""" for link in sorted(glob.glob("/sys/class/hidraw/hidraw*")): try: hid_dev = os.path.realpath(os.path.join(link, "device")) iface = os.path.basename(os.path.dirname(hid_dev)) with open(os.path.join(hid_dev, "modalias")) as f: modalias = f.read().strip() except OSError: continue if (modalias.startswith("hid:b0003g") and modalias.endswith(f"v{VID:08X}p{PID:08X}") and iface.endswith(":1.0")): return f"/dev/{os.path.basename(link)}" raise SystemExit( f"GANSS ARDOR_Katana ({VID:04x}:{PID:04x}) hidraw iface0 not found") class Katana: """Обмен с клавиатурой через hidraw ioctl (только feature-репорты).""" def __init__(self): self.path = find_hidraw() self.fd = os.open(self.path, os.O_RDWR) def close(self): if getattr(self, "fd", None) is not None: os.close(self.fd) self.fd = None # -- низкоуровневые операции -- def _set(self, payload: bytes): """Отправить feature-репорт (SET_REPORT), rid=0.""" buf = bytes([0]) + payload # первый байт буфера = report id fcntl.ioctl(self.fd, _hid_iocsfeature(len(buf)), buf) time.sleep(DELAY) def _get(self) -> bytes: """Прочитать feature-репорт (GET_REPORT), rid=0 → 64 байта данных.""" buf = bytearray(65) buf[0] = 0 # report id fcntl.ioctl(self.fd, _hid_iocgfeature(len(buf)), buf) time.sleep(DELAY) return bytes(buf[1:]) # байт [0] был эхом report id def _cmd(self, opcode: int, block: int = 0) -> bytes: """Отправить команду 04 и вернуть ACK.""" buf = bytearray(64) buf[0], buf[1], buf[8] = 0x04, opcode, block self._set(bytes(buf)) return self._get() @staticmethod def _check(ack: bytes, opcode: int): """Проверить ACK: правильная команда и статус 01.""" if len(ack) < 4 or ack[0] != 0x04 or ack[1] != opcode: raise SystemExit(f"bad ACK for {opcode:#04x}: {ack[:8].hex(' ')}") if ack[3] != 0x01: raise SystemExit( f"command {opcode:#04x} rejected, status={ack[3]:#04x} " f"(try 'reset' if this persists): {ack[:8].hex(' ')}") @staticmethod def _check_checksum(ack: bytes, data: bytes): """Сверить сумму данных с суммой в ACK коммита (байты 4–5, LE16).""" expected = sum(data) & 0xFFFF got = struct.unpack_from("= 4 and ack[3] == 0xFF: # «сессия уже открыта» — вендорский софт тоже продолжает работу print("warning: begin reports session already open", file=sys.stderr) else: self._check(ack, OP_BEGIN) self._check(self._cmd(OP_DATA, 1), OP_DATA) self._set(payload) ack = self._cmd(OP_COMMIT) self._check(ack, OP_COMMIT) self._check_checksum(ack, payload) if save: # Вендор сохраняет после каждого коммита; именно закрытие сессии # через f0 удерживает прошивку (и печать) живыми. self._cmd(OP_SAVE) def reset(self): """USB port reset: снимает клин прошивки без физического переподключения.""" import usb.core dev = usb.core.find(idVendor=VID, idProduct=PID) if dev is None: raise SystemExit("device not found on USB bus") dev.reset() # -- per-key цвета -- def write_key_table(self, colors, save: bool = True): """Записать per-key таблицу: colors — 143 кортежей (r, g, b).""" if len(colors) != KEYS_COUNT: raise ValueError(f"need {KEYS_COUNT} colors, got {len(colors)}") self._check(self._cmd(OP_BEGIN), OP_BEGIN) # data-begin для блока per-key: 04 23 + параметр блока buf = bytearray(64) buf[0], buf[1], buf[8] = 0x04, OP_KEYS_WRITE, KEYS_BLOCK_PARAM self._set(bytes(buf)) self._check(self._get(), OP_KEYS_WRITE) # блоб: 143×[индекс, r, g, b] + 0000 + aa55 = 576 байт blob = bytearray() for i, (r, g, b) in enumerate(colors): blob += bytes([i, r, g, b]) blob += b"\x00\x00\xaa\x55" assert len(blob) == KEYS_BLOB_LEN for off in range(0, KEYS_BLOB_LEN, 64): # 9 пакетов по 64 байта self._set(bytes(blob[off:off + 64])) ack = self._cmd(OP_COMMIT) self._check(ack, OP_COMMIT) self._check_checksum(ack, blob) if save: self._cmd(OP_SAVE) def write_bind_table(self, records, save: bool = True): """Записать таблицу переназначений клавиш (канал 04 11). records — 143 записей по 4 байта: позиция слота = LED-индекс клавиши, содержимое — назначенное действие. Для кнопок мыши запись эмпирически равна [01, 01, код_кнопки, 00] (см. MOUSE_ACTIONS), для функций редактора и горячих клавиш — [02, маска_модификаторов, HID-код, 00] (см. EDITOR_ACTIONS, дамп keybind-editor и keybind-hotkeys), для мультимедиа — [03, usage u16 LE, 00] (см. MULTIMEDIA_ACTIONS, дамп keybind-multimedia), действие по умолчанию — нули. Формат блоба совпадает с per-key таблицей: 143×4 + 0000 + aa55. ВАЖНО: команда перезаписывает ВСЮ таблицу (как вендорская утилита) — переназначения, не указанные в records, сбрасываются в default. Команда чтения текущих переназначений неизвестна. """ if len(records) != BIND_COUNT: raise ValueError(f"need {BIND_COUNT} records, got {len(records)}") ack = self._cmd(OP_BEGIN) if ack[:2] == b"\x04\x18" and len(ack) >= 4 and ack[3] == 0xFF: # «сессия уже открыта» — вендорский софт тоже продолжает работу print("warning: begin reports session already open", file=sys.stderr) else: self._check(ack, OP_BEGIN) # data-begin для блока переназначений: 04 11 + параметр блока buf = bytearray(64) buf[0], buf[1], buf[8] = 0x04, OP_BIND_WRITE, KEYS_BLOCK_PARAM self._set(bytes(buf)) self._check(self._get(), OP_BIND_WRITE) blob = bytearray() for rec in records: blob += bytes(rec) blob += b"\x00\x00\xaa\x55" assert len(blob) == BIND_BLOB_LEN for off in range(0, BIND_BLOB_LEN, 64): # 9 пакетов по 64 байта self._set(bytes(blob[off:off + 64])) ack = self._cmd(OP_COMMIT) self._check(ack, OP_COMMIT) self._check_checksum(ack, blob) if save: self._cmd(OP_SAVE) def write_macro_table(self, macros, save=False): """Записать содержимое макросов (канал 04 19, дамп macros-create). macros — список слотов; слот — список событий (код, тип, задержка_мс), сборка блоба — build_macro_blob(). Транзакция отличается от обычной: begin — сразу 04 19 (без 04 18), data-begin — 04 15, где байт [8] = число 64-байтовых пакетов; после коммита вендор НЕ делает save. ВАЖНО: команда перезаписывает ВСЕ слоты макросов — не указанные стираются (как у таблицы переназначений). Несколько макросов нужно записывать за ОДИН вызов, передав все слоты сразу (проверено железом: два слота в одном блобе живут независимо). Чтение содержимого из прошивки неизвестно. ЕЩЁ ВАЖНО (проверено железом): после записи макросов bind-таблица (remap) и режим подсветки сбиваются В RAM — перепошлите их (apply делает это автоматически). """ blob = build_macro_blob(macros) ack = self._cmd(OP_MACRO_BEGIN) if ack[:2] == b"\x04\x19" and len(ack) >= 4 and ack[3] == 0xFF: # «сессия уже открыта» — вендорский софт тоже продолжает работу print("warning: begin reports session already open", file=sys.stderr) else: self._check(ack, OP_MACRO_BEGIN) # data-begin: 04 15, байт [8] = число 64-байтовых пакетов buf = bytearray(64) buf[0], buf[1], buf[8] = 0x04, OP_MACRO_DATA, len(blob) // 64 self._set(bytes(buf)) self._check(self._get(), OP_MACRO_DATA) for off in range(0, len(blob), 64): self._set(blob[off:off + 64]) ack = self._cmd(OP_COMMIT) self._check(ack, OP_COMMIT) self._check_checksum(ack, blob) if save: # вендор после записи макросов save НЕ делает; опция на случай, # если содержимое не переживает переподключение self._cmd(OP_SAVE) def show_custom(self, brightness: int = 0x0F, save: bool = True): """Переключить подсветку в режим 0x80 (отображение per-key таблицы).""" buf = bytearray(64) buf[0] = MODE_CUSTOM buf[9] = brightness buf[14], buf[15] = 0xAA, 0x55 self.lighting(bytes(buf), save=save) def read_frame(self): """Живой per-key кадр через 04 f5 (вендорская схема). SET f5 запускает свежий поток (указатель сбрасывается на 0x00), прошивка отвечает 9 GET-пакетами [индекс, R, G, B]×16 клавиш, покрывающими 0x00..0x8f. Первый GET может быть ACK (04 f5 00 01) вместо данных — наблюдается на первом чтении после save, — поэтому посторонние не-данные пропускаются. Каждое чтение закрывается commit (+save). ВАЖНО: data-begin (04 13) перед f5 делать НЕЛЬЗЯ — он разоружает поток (status 00, GET-ы возвращают эхо). """ expect = [0x00, 0x10, 0x20, 0x30, 0x40, 0x50, 0x60, 0x70, 0x80] for _ in range(3): buf = bytearray(64) buf[0], buf[1], buf[8] = 0x04, OP_KEYS_READ, KEYS_BLOCK_PARAM self._set(bytes(buf)) # 9 пакетов данных + запас на ACK/эхо GET-ы packets, received = {}, 0 for _ in range(12): packet = self._get() if packet[:2] == b"\x04\xf5": # ACK или эхо — пропустить continue if received < 9 and packet[0] == expect[received]: packets[received] = packet received += 1 if received == 9: break ack = self._cmd(OP_COMMIT) self._cmd(OP_SAVE) if received == 9 and ack[3] == 0x01: # собрать слоты из 9 пакетов по 16 клавиш slots = {} for i in range(9): for off in range(0, 64, 4): idx, r, g, b = packets[i][off:off + 4] slots[idx] = (r, g, b) return [slots.get(i, (0, 0, 0)) for i in range(KEYS_COUNT)] # поток не совпал: переоткрыть контекст сессии и повторить self._cmd(OP_BEGIN) raise SystemExit("keys: f5 stream did not align after 3 attempts " "(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): """Собрать блоб содержимого макросов для канала 04 19. macros — список слотов (индекс = номер макроса, 0-based); слот — список событий (код, тип, задержка_мс); пустой список = свободный слот. Формат (дамп macros-create): заголовок 400 байт — u32-смещения слотов (0 = слот пуст), затем на каждый макрос: u32 (число событий × 2), u32 0 и события по 8 байт [00 00][код][тип][delay u16 LE][00][50]. Хвост: нули до кратности 64 минус 2 байта + aa55 (как у per-key блоба). """ header = bytearray(MACRO_HEADER_LEN) data = bytearray() for idx, events in enumerate(macros): if not events: continue if idx * 4 >= MACRO_HEADER_LEN: raise ValueError(f"macro index {idx} out of range " f"(0..{MACRO_HEADER_LEN // 4 - 1})") # смещение данных слота от начала блоба; 0 = свободный слот header[idx * 4:idx * 4 + 4] = struct.pack( "> 8, 0, 0x50]) blob = bytes(header) + bytes(data) if len(blob) > MACRO_MAX_BLOB - 2: raise ValueError(f"macro blob needs {len(blob)} bytes, firmware " f"capacity ~{MACRO_MAX_BLOB} — use fewer events") blob += b"\x00" * ((62 - len(blob)) % 64) + b"\xaa\x55" return blob def parse_macro_tokens(tokens): """Разобрать текстовые токены макроса в список событий. Токен: [+-]имя[@задержка_мс]. '+' — нажать, '-' — отпустить, без знака — нажать и отпустить (tap). Задержка — пауза ПОСЛЕ события (семантика задержки у вендора не установлена, выбрана для определённости). Имена клавиш — MACRO_KEYS, кнопки мыши — MOUSE_ACTIONS (lmb, rmb, ...). """ events = [] for token in tokens: m = re.fullmatch(r"([+-]?)([a-z0-9]+)(?:@(\d+))?", token) if not m: raise ValueError(f"bad token {token!r} " "(expected [+-]name[@delay_ms])") sign, name, delay = m.groups() delay = int(delay) if delay else 0 if name in MOUSE_ACTIONS: code, down, up = MOUSE_ACTIONS[name], MACRO_MOUSE_DOWN, MACRO_MOUSE_UP elif name in MACRO_KEYS: code, down, up = MACRO_KEYS[name], MACRO_KEY_DOWN, MACRO_KEY_UP else: raise ValueError(f"unknown key {name!r} (names in MACRO_KEYS)") if sign == "+": events.append((code, down, delay)) elif sign == "-": events.append((code, up, delay)) else: # tap: нажать и отпустить events.append((code, down, 0)) events.append((code, up, delay)) return events def parse_macro_xml(path): """Разобрать вендорский XML макроса (формат raw-data/.../test.xml). item type: 2/3 = клавиша вниз/вверх (value = VK-код Windows), 4/5 = кнопка мыши вниз/вверх (value: 1 = ЛКМ, 2 = СКМ, 3 = ПКМ). """ import xml.etree.ElementTree as ET try: root = ET.parse(path).getroot() except (ET.ParseError, OSError) as e: raise SystemExit(f"macro: cannot parse {path}: {e}") events = [] for item in root.iter("item"): try: itype, value = int(item.get("type")), int(item.get("value")) delay = int(item.get("delay_time", "0")) except (TypeError, ValueError): raise SystemExit(f"macro: bad item attributes in {path}: " f"{item.attrib}") if itype in (2, 3): code = VK_TO_MACRO.get(value) if code is None: raise SystemExit(f"macro: unsupported VK code {value} " f"({item.get('desc')!r}) in {path}") etype = MACRO_KEY_DOWN if itype == 2 else MACRO_KEY_UP elif itype in (4, 5): code = VK_TO_MOUSE.get(value) if code is None: raise SystemExit(f"macro: unsupported mouse button {value} " f"({item.get('desc')!r}) in {path}") etype = MACRO_MOUSE_DOWN if itype == 4 else MACRO_MOUSE_UP else: raise SystemExit(f"macro: unknown item type {itype} in {path}") events.append((code, etype, delay)) if not events: raise SystemExit(f"macro: no items found in {path}") return events def macro_to_xml(events, name="Macro"): """Сериализовать события обратно в вендорский XML (парный к parse_macro_xml).""" lines = ['', "", f'', ""] for code, etype, delay in events: if etype == MACRO_KEY_DOWN: itype, value = 2, MACRO_TO_VK.get(code) elif etype == MACRO_KEY_UP: itype, value = 3, MACRO_TO_VK.get(code) elif etype == MACRO_MOUSE_DOWN: itype, value = 4, MACRO_TO_VK_MOUSE.get(code) elif etype == MACRO_MOUSE_UP: itype, value = 5, MACRO_TO_VK_MOUSE.get(code) else: raise ValueError(f"unknown macro event type {etype:#04x}") if value is None: raise ValueError(f"no VK code for macro key {code:#04x}") desc = MACRO_CODE_NAMES.get(code, f"0x{code:02x}") lines.append(f'') lines += ["", ""] return "\n".join(lines) + "\n" def macro_event_str(event): """Событие в форме токена: +a@100, -lmb, ... (для показа).""" code, etype, delay = event if etype in (MACRO_KEY_DOWN, MACRO_KEY_UP): name = MACRO_CODE_NAMES.get(code, f"0x{code:02x}") elif etype in (MACRO_MOUSE_DOWN, MACRO_MOUSE_UP): name = MACRO_MOUSE_NAMES.get(code, f"0x{code:02x}") else: name = f"0x{code:02x}?t{etype:#02x}" sign = "+" if etype in (MACRO_KEY_DOWN, MACRO_MOUSE_DOWN) else "-" return f"{sign}{name}@{delay}" if delay else f"{sign}{name}" def parse_hotkey(spec): """Разобрать действие remap «горячая клавиша»: [MOD+...]КЛАВИША. MOD — имена из HOTKEY_MODIFIERS (ctrl/shift/alt/meta), можно комбинировать через «+» (ctrl+shift+x → маска 0x03); КЛАВИША — имя из HOTKEY_KEYS. Регистр не важен. Возвращает (маска_модификаторов, HID-код) или None, если формат или имя не распознаны. """ parts = spec.split("+") key = parts[-1].lower() mods = 0 for part in parts[:-1]: mask = HOTKEY_MODIFIERS.get(part.lower()) if mask is None: return None mods |= mask code = HOTKEY_KEYS.get(key) if code is None: return None return mods, code def parse_macro_action(spec): """Разобрать действие remap: macro, macro:K, macro-toggle. Возвращает (индекс_макроса, режим, счётчик) для записи канала 04 11 [06, idx, mode, count] (дамп macros-modes): mode 00 = однократно, 01 = повторить count раз, 02 = до повторного нажатия (toggle). Неизвестный формат → None. """ m = re.fullmatch(r"macro(\d+)(?::(\d+)|-toggle)?", spec) if not m: return None idx = int(m.group(1)) if m.group(2) is not None: # macro:K — повторить K раз return idx, 0x01, int(m.group(2)) if spec.endswith("-toggle"): # macro-toggle — до останова return idx, 0x02, 0 return idx, 0x00, 0 # macro — однократно # ---- CLI ------------------------------------------------------------------------ def build_parser(): """Собрать argparse-парсер всех подкоманд (используется main и apply).""" parser = argparse.ArgumentParser(description="GANSS ARDOR_Katana RGB control") sub = parser.add_subparsers(dest="cmd", required=True) pm = sub.add_parser( "mode", help="set lighting mode: number 1..19 or alias (static, keypress, ..., shuttle; off)") pm.add_argument("n", type=parse_mode) pm.add_argument("--color", type=parse_color, default=(0xFF, 0xFF, 0xFF), help="RRGGBB hex or name (red, green, ..., white, orange)") pm.add_argument("--brightness", type=int, default=0x0F, help=f"1..15 (default {0x0F})") pm.add_argument("--speed", type=int, default=None, help=f"2..15, animated modes only (default {SPEED_STATIC})") pm.add_argument("--flag", type=int, default=None, help="override per-mode flag byte (vendor defaults used if omitted)") pm.add_argument("--rainbow", action="store_true", help="mode static: full rainbow spectrum instead of --color") pm.add_argument("--direction", choices=["north-south", "south-north", "east-west", "west-east"], help="animation direction (waterfall, flow, rotate, marquee, wind; " "vendor defaults if omitted)") pm.add_argument("--no-save", action="store_true", help="skip flash write (leaves session open; use with care)") sub.add_parser("off", help="backlight off (vendor list last item)") sub.add_parser("default", help="send vendor default payload (0x80)") pr = sub.add_parser("raw", help="raw 64-byte payload hex inside a transaction") pr.add_argument("hexstr") pr.add_argument("--no-save", action="store_true") ps = sub.add_parser("scan", help="cycle modes one by one for visual ID") ps.add_argument("--from", dest="first", type=int, default=MODE_MIN) ps.add_argument("--to", dest="last", type=int, default=MODE_MAX) ps.add_argument("--color", type=parse_color, default=(0xFF, 0x00, 0x00)) ps.add_argument("--brightness", type=int, default=0x0F) ps.add_argument("--speed", type=int, default=0x0A) ps.add_argument("--delay", type=float, default=2.0) sub.add_parser("reset", help="USB port reset (unwedge firmware, no replug)") pp = sub.add_parser("paint", help="per-key colors (writes table, shows mode 0x80)") pp.add_argument("--all", type=parse_color, default=None, help="paint every key this color (hex or name)") pp.add_argument("--key", action="append", default=[], metavar="KEY=COLOR", help="paint one key (repeatable), e.g. --key Tab=green") pp.add_argument("--wasd", type=parse_color, default=None, metavar="COLOR", help="paint W, A, S, D this color (shorthand for four --key)") pp.add_argument("--numpad", type=parse_color, default=None, metavar="COLOR", help="paint the whole numpad this color (shorthand for --key)") pp.add_argument("--alpha", type=parse_color, default=None, metavar="COLOR", help="paint all letter keys (A-Z) this color (shorthand for --key)") pp.add_argument("--punct", type=parse_color, default=None, metavar="COLOR", help=r"paint punctuation keys [];',./\ and space this color (shorthand for --key)") pp.add_argument("--digits", type=parse_color, default=None, metavar="COLOR", help=r"paint the number row keys ~1234567890-= this color (shorthand for --key)") pp.add_argument("--arrows", type=parse_color, default=None, metavar="COLOR", help="paint the four arrow keys this color (shorthand for --key)") for row_n, row_keys in ROW_KEYS.items(): pp.add_argument(f"--row{row_n}", type=parse_color, default=None, metavar="COLOR", help=f"paint row {row_n} ({len(row_keys)} keys: " f"{row_keys[0]}..{row_keys[-1]}) " f"this color (shorthand for --key)") pp.add_argument("--black", action="store_true", help="start from all-black table") pp.add_argument("--keep", action="store_true", help="start from the current live frame (04 f5 read), " "so all other keys keep their present colors; " "use together with --key") pp.add_argument("--brightness", type=int, default=0x0F) pp.add_argument("--no-save", action="store_true") sub.add_parser("keys", help="read live per-key frame (04 f5)") bp = sub.add_parser("remap", help="remap keys to mouse buttons/macros (writes 04 11 table)") bp.add_argument("--key", action="append", default=[], metavar="KEY=ACTION", help="remap one key (repeatable), e.g. --key lctrl=lmb; " "ACTION: " + "|".join(MOUSE_ACTIONS) + "|" + "|".join(EDITOR_ACTIONS) + "|" + "|".join(MULTIMEDIA_ACTIONS) + "|hotkey MOD+KEY|default|macro|macro:K|" "macro-toggle " "(hotkey: MOD = ctrl|shift|alt|meta, " "KEY = a..z|0..9|f1..f12|esc|tab|enter|space|caps|" "backspace|num0..num9|fn)") bp.add_argument("--clear", action="store_true", help="reset ALL remaps to default (writes all-zero table)") bp.add_argument("--no-save", action="store_true") mc = sub.add_parser("macro", help="macro contents (04 19) — set/clear/show/export") msub = mc.add_subparsers(dest="macro_cmd", required=True) ms = msub.add_parser("set", help="write macro content to slot N (erases other slots)") ms.add_argument("index", type=int, help="macro slot 0-based (vendor 'Макрос 1' = 0)") ms.add_argument("tokens", nargs="*", help="events: [+-]name[@delay_ms], e.g. +lctrl a@50 -lctrl; " "use '--' before tokens starting with '-'") ms.add_argument("--xml", metavar="FILE", help="take events from vendor XML file instead of tokens") ms.add_argument("--save", action="store_true", help="send 04 f0 after commit (vendor does NOT save here)") mcl = msub.add_parser("clear", help="erase ALL macro slots") mcl.add_argument("--save", action="store_true") msh = msub.add_parser("show", help="print events from vendor XML file (offline)") msh.add_argument("file") mex = msub.add_parser("export", help="build vendor XML from tokens (offline)") mex.add_argument("file") mex.add_argument("tokens", nargs="+", help="use '--' before tokens starting with '-'") mex.add_argument("--name", default="Macro", help="macro name for XML") ap = sub.add_parser("apply", help="apply YAML config (see katana.yaml.example); " "sections run in order: macros → remap → paint → lighting → raw") ap.add_argument("config", help="path to YAML config file") return parser def dispatch(args, keyboard: Katana): """Выполнить разобранную подкоманду (общий для main и apply).""" if args.cmd == "mode": cmd_mode(keyboard, args) elif args.cmd == "off": cmd_off(keyboard) elif args.cmd == "default": cmd_default(keyboard) elif args.cmd == "raw": cmd_raw(keyboard, args) elif args.cmd == "scan": cmd_scan(keyboard, args) elif args.cmd == "reset": cmd_reset(keyboard) elif args.cmd == "paint": cmd_paint(keyboard, args) elif args.cmd == "keys": cmd_keys(keyboard) elif args.cmd == "remap": cmd_remap(keyboard, args) elif args.cmd == "macro": cmd_macro(keyboard, args) elif args.cmd == "apply": cmd_apply(keyboard, args) def main(): args = build_parser().parse_args() keyboard = open_katana() try: dispatch(args, keyboard) finally: keyboard.close() # ---- apply: YAML-конфиг --------------------------------------------------------- # Порядок применения секций конфига. # КЛЮЧЕВОЕ ОГРАНИЧЕНИЕ (проверено на железе): save (04 f0) коммитит во флеш # ВСЮ RAM прошивки ЦЕЛИКОМ (bind-таблицу, paint-таблицу, режим, макросы). # Поэтому apply применяет ВСЕ секции с --no-save, а единственный save делает # последняя применённая секция: её содержимое уже в RAM, и save фиксирует # всё остальное вместе с ним. Если секция macros присутствует, она идёт # последней (запись макросов сбивает режим подсветки — см. cmd_apply). APPLY_SECTION_ORDER = ("remap", "paint", "lighting", "raw", "macros") # Допустимые ключи секций конфига (для защиты от опечаток). # Ключ 'save' в секциях НЕ поддерживается: apply сам управляет save'ами # (каждая секция несёт свой save, после macros remap и lighting повторяются). CONFIG_KEYS = { "remap": {"keys", "clear"}, "paint": {"all", "keys", "wasd", "numpad", "alpha", "punct", "digits", "arrows", "rows", "black", "keep", "brightness"}, "lighting": {"mode", "color", "brightness", "speed", "flag", "rainbow", "direction"}, } def load_config(path): """Прочитать YAML-конфиг и проверить состав секций.""" try: import yaml except ImportError: raise SystemExit("apply: PyYAML is required: pip install pyyaml " "(or .venv/bin/pip install pyyaml)") try: with open(path, encoding="utf-8") as f: cfg = yaml.safe_load(f) except FileNotFoundError: raise SystemExit(f"apply: config not found: {path}") except yaml.YAMLError as e: raise SystemExit(f"apply: bad YAML in {path}: {e}") if not isinstance(cfg, dict): raise SystemExit(f"apply: {path}: top level must be a mapping " f"of sections ({', '.join(APPLY_SECTION_ORDER)})") unknown = set(cfg) - set(APPLY_SECTION_ORDER) if unknown: raise SystemExit(f"apply: {path}: unknown sections " f"{', '.join(sorted(map(str, unknown)))} " f"(allowed: {', '.join(APPLY_SECTION_ORDER)})") return cfg def _check_section_keys(section, values): """Ругнуться на опечатки в ключах секции конфига.""" extra = set(values) - CONFIG_KEYS[section] if extra: raise SystemExit(f"apply: unknown keys in [{section}]: " f"{', '.join(sorted(map(str, extra)))} " f"(allowed: {', '.join(sorted(CONFIG_KEYS[section]))})") def _bool_flag(argv, values, key, flag, section): """Добавить флаг в argv, если ключ секции истинный.""" if values.get(key): argv.append(flag) def config_argv(section, values, save): """Секция конфига → список аргументов существующей подкоманды CLI. Применение идёт через build_parser().parse_args(argv) и dispatch(), поэтому вся валидация (parse_color, parse_mode, диапазоны) — та же, что у обычных команд. save — принудительный режим сохранения: apply всегда передаёт False (save делает только последняя секция, см. APPLY_SECTION_ORDER), ключ 'save' в секции не поддерживается. """ _check_section_keys(section, values) if not isinstance(values, dict): raise SystemExit(f"apply: [{section}] must be a mapping") if section == "remap": argv = ["remap"] keys = values.get("keys") or {} if not isinstance(keys, dict): raise SystemExit("apply: [remap] 'keys' must be a KEY: ACTION mapping") for key, action in keys.items(): argv += ["--key", f"{key}={action}"] _bool_flag(argv, values, "clear", "--clear", section) if not save: argv.append("--no-save") return argv if section == "paint": argv = ["paint"] if values.get("all") is not None: argv += ["--all", str(values["all"])] keys = values.get("keys") or {} if not isinstance(keys, dict): raise SystemExit("apply: [paint] 'keys' must be a KEY: COLOR mapping") for key, color in keys.items(): argv += ["--key", f"{key}={color}"] for name in ("wasd", "numpad", "alpha", "punct", "digits", "arrows"): if values.get(name) is not None: argv += [f"--{name}", str(values[name])] rows = values.get("rows") or {} if not isinstance(rows, dict): raise SystemExit("apply: [paint] 'rows' must be a N: COLOR mapping") for n, color in rows.items(): argv += [f"--row{int(n)}", str(color)] _bool_flag(argv, values, "black", "--black", section) _bool_flag(argv, values, "keep", "--keep", section) if values.get("brightness") is not None: argv += ["--brightness", str(values["brightness"])] if not save: argv.append("--no-save") return argv if section == "lighting": mode = values.get("mode") if mode is None: raise SystemExit("apply: [lighting] needs 'mode' " "(name/number 1..19, off or default)") mode_s = str(mode).lower() if mode_s == "off": return ["off"] if save else ["off", "--no-save"] if mode_s == "default": # default — это сброс к вендорским значениям, он сам делает save return ["default"] argv = ["mode", mode_s] if values.get("color") is not None: argv += ["--color", str(values["color"])] for name in ("brightness", "speed", "flag"): if values.get(name) is not None: argv += [f"--{name}", str(values[name])] _bool_flag(argv, values, "rainbow", "--rainbow", section) if values.get("direction") is not None: argv += ["--direction", str(values["direction"])] if not save: argv.append("--no-save") return argv raise SystemExit(f"apply: section {section!r} is handled directly " f"(internal error)") def macro_argv(slot, spec, save): """Запись слота макроса из конфига → argv команды macro set. save — принудительный режим сохранения (контролирует cmd_apply: save делает только последняя секция apply). Ключ 'save' в слоте игнорируется. """ if not isinstance(spec, dict): raise SystemExit(f"apply: [macros] slot {slot} must be a mapping " f"with 'tokens' or 'xml'") extra = set(spec) - {"tokens", "xml", "save"} if extra: raise SystemExit(f"apply: unknown keys in [macros] slot {slot}: " f"{', '.join(sorted(map(str, extra)))} " "(allowed: tokens, xml, save)") has_tokens, has_xml = "tokens" in spec, "xml" in spec if has_tokens == has_xml: # либо оба, либо ни одного raise SystemExit(f"apply: [macros] slot {slot}: use either " "'tokens' or 'xml', not both/neither") try: idx = int(slot) except (TypeError, ValueError): raise SystemExit(f"apply: [macros] slot {slot!r} must be an integer 0..99") argv = ["macro", "set", str(idx)] # --save строго ДО '--': после '--' argparse считает всё позиционными # аргументами, и флаг попал бы в токены if save: argv.append("--save") if has_xml: argv += ["--xml", str(spec["xml"])] else: tokens = spec["tokens"] if not isinstance(tokens, list) or not tokens: raise SystemExit(f"apply: [macros] slot {slot}: 'tokens' must be " "a non-empty list, e.g. ['+lctrl', 'a@50', '-lctrl']") argv.append("--") # токены с '-' argparse примет за опции argv += [str(t) for t in tokens] return argv def cmd_apply(k: Katana, args): """Применить YAML-конфиг: секции прогоняются через существующие команды. Схема (проверена на железе): - каждая область (bind, paint, режим, макросы) сохраняется во флеш ТОЛЬКО своим собственным save (04 f0); чужой save чужую область не сохраняет и не затирает; - запись макросов (04 19) сбивает bind-таблицу и режим подсветки В RAM (во флеше они остаются), поэтому после секции macros remap и lighting применяются повторно (со save — заодно фиксируя себя во флеше); - все слоты макросов пишутся ОДНИМ блобом: несколько вызовов macro set обнуляют ранее записанные слоты. """ cfg = load_config(args.config) # список секций к применению (в порядке APPLY_SECTION_ORDER) sections = [s for s in APPLY_SECTION_ORDER if s in cfg and cfg[s] is not None] if not sections: print("config applied: nothing to do (no sections)") return ran = [] for i, section in enumerate(sections): is_last = i == len(sections) - 1 if section == "macros": slots = cfg[section] if not isinstance(slots, dict) or not slots: raise SystemExit("apply: [macros] must be a SLOT: SPEC mapping") # ВСЕ слоты одним блобом (иначе каждый вызов обнуляет прочие) macro_slots = _build_macro_slots(slots) print(f"apply [macros x{len(macro_slots)}] ... (single blob)") k.write_macro_table(macro_slots, save=True) ran.append(f"macros x{len(macro_slots)}") elif section == "raw": payload = cfg[section] if isinstance(payload, list): payload = " ".join(str(p) for p in payload) print("apply [raw] ...") _apply_argv(["raw", str(payload)], k, "[raw]") ran.append("raw") else: print(f"apply [{section}] ...") _apply_argv(config_argv(section, cfg[section], save=is_last), k, f"[{section}]") ran.append(section) # После макросов bind и режим сбиты в RAM — перепошлём их со save. if "macros" in sections: if "remap" in cfg and cfg["remap"] is not None: print("apply [remap re-apply] ... (after macros)") _apply_argv(config_argv("remap", cfg["remap"], save=True), k, "[remap re-apply]") ran.append("remap (re-apply)") if "lighting" in cfg and cfg["lighting"] is not None: print("apply [lighting re-apply] ... (after macros)") _apply_argv(config_argv("lighting", cfg["lighting"], save=True), k, "[lighting re-apply]") ran.append("lighting (re-apply)") print(f"config applied: {', '.join(ran)}") def _build_macro_slots(slots_cfg): """Секция macros → список слотов для write_macro_table (один блоб). slots_cfg — {номер_слота: {'tokens': [...]} | {'xml': путь}}. """ max_idx = -1 parsed = {} for slot, spec in slots_cfg.items(): try: idx = int(slot) except (TypeError, ValueError): raise SystemExit(f"apply: [macros] slot {slot!r} must be an " f"integer 0..{MACRO_HEADER_LEN // 4 - 1}") if not 0 <= idx < MACRO_HEADER_LEN // 4: raise SystemExit(f"apply: [macros] slot {idx} out of range") extra = set(spec) - {"tokens", "xml"} if extra: raise SystemExit(f"apply: unknown keys in [macros] slot {idx}: " f"{', '.join(sorted(map(str, extra)))} " "(allowed: tokens, xml)") has_tokens, has_xml = "tokens" in spec, "xml" in spec if has_tokens == has_xml: raise SystemExit(f"apply: [macros] slot {idx}: use either " "'tokens' or 'xml', not both/neither") if has_xml: try: parsed[idx] = parse_macro_xml(str(spec["xml"])) except ValueError as e: raise SystemExit(f"apply: [macros] slot {idx}: {e}") else: tokens = spec["tokens"] if not isinstance(tokens, list) or not tokens: raise SystemExit(f"apply: [macros] slot {idx}: 'tokens' must " "be a non-empty list, e.g. " "['+lctrl', 'a@50', '-lctrl']") try: parsed[idx] = parse_macro_tokens([str(t) for t in tokens]) except ValueError as e: raise SystemExit(f"apply: [macros] slot {idx}: {e}") max_idx = max(max_idx, idx) # список слотов 0..max_idx, пропуски — пустые слоты return [parsed.get(i, []) for i in range(max_idx + 1)] def _apply_argv(argv, k: Katana, label): """Разобрать argv существующим парсером и выполнить (ошибки — с меткой).""" try: ns = build_parser().parse_args(argv) except SystemExit: raise SystemExit(f"apply: invalid values in {label}, see error above") dispatch(ns, k) def cmd_mode(k: Katana, args): """Установить режим освещения.""" check_range(args.n, MODE_MIN, MODE_MAX, "mode") check_range(args.brightness, BRIGHT_MIN, BRIGHT_MAX, "brightness") speed = args.speed if args.speed is not None else SPEED_STATIC check_range(speed, 1, 0x0F, "speed") if args.n == 1: # у статики нет скорости — прошивке всегда шлём вендорское значение if args.speed is not None and args.speed != SPEED_STATIC: print(f"warning: static mode has no speed, " f"sending vendor default {SPEED_STATIC:#04x}", file=sys.stderr) speed = SPEED_STATIC elif speed < SPEED_MIN: print(f"warning: speed {speed} below observed range " f"{SPEED_MIN}..{SPEED_MAX}", file=sys.stderr) flag = args.flag if args.flag is not None else FLAG_DEFAULTS.get(args.n, 0x01) # байт [11]: моно/радуга для статики, направление для анимаций submode = 0x00 extra = "" if args.rainbow: submode = 0x01 extra = " rainbow" if args.direction: directions = DIRECTION_BYTES.get(args.n) if directions is None: raise SystemExit(f"mode {args.n} has no direction setting") name_a, byte_a, name_b, byte_b = directions if args.direction not in (name_a, name_b): raise SystemExit(f"mode {args.n} directions: {name_a}, {name_b}") submode = byte_a if args.direction == name_a else byte_b extra = f" dir={args.direction}" payload = build_payload(args.n, args.color, flag, args.brightness, speed, submode) k.lighting(payload, save=not args.no_save) print(f"mode {args.n} color={'%02x%02x%02x' % args.color} " f"bright={args.brightness:#x} speed={speed:#x} " f"flag={flag:#x} submode={submode:#x}{extra} " f"sent (save={'yes' if not args.no_save else 'no'})") def cmd_off(k: Katana): """Выключить подсветку: нулевой payload + aa55.""" payload = bytearray(64) payload[14], payload[15] = 0xAA, 0x55 k.lighting(bytes(payload)) print("backlight off") def cmd_default(k: Katana): """Вендорский дефолтный payload (режим 0x80, пустая таблица).""" payload = bytes.fromhex("8000000000000000000f00000000aa55" + "00" * 48) k.lighting(payload) print("vendor default payload sent") def cmd_raw(k: Katana, args): """Произвольный payload (дополнить нулями до 64 байт, обрезать лишнее).""" payload = bytes.fromhex(args.hexstr.replace(" ", "")) payload = payload.ljust(64, b"\x00")[:64] k.lighting(payload, save=not args.no_save) print("raw payload sent") def cmd_scan(k: Katana, args): """Перебрать режимы по одному с паузой — для визуальной идентификации.""" accepted, rejected = [], [] for n in range(args.first, args.last + 1): payload = build_payload(n, args.color, FLAG_DEFAULTS.get(n, 0x01), args.brightness, args.speed) try: k.lighting(payload, save=True) except SystemExit as e: print(f"mode {n:3d} rejected ({e})") rejected.append(n) continue accepted.append(n) print(f"mode {n:3d} color={'%02x%02x%02x' % args.color} " f"bright={args.brightness:#x} speed={args.speed:#x} sent") time.sleep(args.delay) print(f"\naccepted: {accepted}\nrejected: {rejected}") def cmd_reset(k: Katana): """USB port reset: перечисление ~2 с, клавиатура не отваливается.""" k.reset() print("USB port reset done - wait ~2s for re-enumeration") def cmd_paint(k: Katana, args): """Записать per-key таблицу и показать её (режим 0x80).""" check_range(args.brightness, BRIGHT_MIN, BRIGHT_MAX, "brightness") # --wasd / --numpad / --alpha / --punct — shorthand для групп --key if args.wasd: wasd_color = "%02x%02x%02x" % args.wasd args.key += [f"{key}={wasd_color}" for key in ("w", "a", "s", "d")] if args.numpad: numpad_color = "%02x%02x%02x" % args.numpad args.key += [f"{key}={numpad_color}" for key in NUMPAD_KEYS] if args.alpha: alpha_color = "%02x%02x%02x" % args.alpha args.key += [f"{key}={alpha_color}" for key in ALPHA_KEYS] if args.punct: punct_color = "%02x%02x%02x" % args.punct args.key += [f"{key}={punct_color}" for key in (*PUNCT_KEYS, SPACE_KEY)] if args.digits: digits_color = "%02x%02x%02x" % args.digits args.key += [f"{key}={digits_color}" for key in DIGITS_KEYS] if args.arrows: arrows_color = "%02x%02x%02x" % args.arrows args.key += [f"{key}={arrows_color}" for key in ARROW_KEYS] for row_n in ROW_KEYS: row_color = getattr(args, f"row{row_n}") if row_color: row_color_s = "%02x%02x%02x" % row_color args.key += [f"{key}={row_color_s}" for key in ROW_KEYS[row_n]] if args.keep: if args.all or args.black: raise SystemExit("paint: --keep cannot be combined with --all/--black") if not args.key: raise SystemExit("paint: --keep is used with --key " "(otherwise there is nothing to change)") # база = текущий живой кадр: все прочие клавиши сохраняют свои цвета print("reading current frame (04 f5)...") table = k.read_frame() else: if not args.all and not args.key: raise SystemExit("paint: specify --all COLOR and/or --key KEY=COLOR") # база: чёрная (--black или только --key) либо заливка --all base = (0, 0, 0) if (args.black or not args.all) else args.all table = [base] * KEYS_COUNT for spec in args.key: # split по ПОСЛЕДНЕМУ '=': имя клавиши может содержать '=' (клавиша =) key_s, sep, color_s = spec.rpartition("=") if not sep or not key_s: raise SystemExit(f"paint: bad --key spec {spec!r} (KEY=RRGGBB)") color = parse_color(color_s) idx = resolve_key(key_s) if idx is None or not 0 <= idx < KEYS_COUNT: raise SystemExit(f"paint: unknown key or index {key_s!r} " f"(names in keymap.py, hex index 0x00..0x8e)") table[idx] = color k.write_key_table(table, save=not args.no_save) k.show_custom(args.brightness, save=not args.no_save) painted = sum(1 for c in table if c != (0, 0, 0)) print(f"key table written ({painted} painted), " f"custom mode 0x80 shown, brightness={args.brightness:#x}") def cmd_keys(k: Katana): """Прочитать и напечатать живой кадр сеткой по 16 слотов.""" frame = k.read_frame() for row in range(0, len(frame), 16): cells = [] for idx in range(row, min(row + 16, len(frame))): r, g, b = frame[idx] cells.append("......" if (r, g, b) == (0, 0, 0) else f"{r:02x}{g:02x}{b:02x}") print(f"{row:02x}+: " + " ".join(cells)) def cmd_remap(k: Katana, args): """Записать таблицу переназначений клавиш (канал 04 11).""" if args.clear and args.key: raise SystemExit("remap: --clear cannot be combined with --key") if not args.clear and not args.key: raise SystemExit("remap: specify --key KEY=ACTION and/or --clear") # таблица по умолчанию: все клавиши выполняют своё стандартное действие records = [(0, 0, 0, 0)] * BIND_COUNT # канонические имена действий для вывода (первое имя из MOUSE_ACTIONS) canon = {} for name, code in MOUSE_ACTIONS.items(): canon.setdefault(code, name) written = [] for spec in args.key: # split по ПОСЛЕДНЕМУ '=': имя клавиши может содержать '=' (клавиша =) key_s, sep, act_s = spec.rpartition("=") if not sep or not key_s: raise SystemExit(f"remap: bad --key spec {spec!r} (KEY=ACTION)") idx = resolve_key(key_s) if idx is None or not 0 <= idx < BIND_COUNT: raise SystemExit(f"remap: unknown key or index {key_s!r} " f"(names in keymap.py, hex index 0x00..0x8e)") if act_s in ("default", "none", "off"): records[idx] = (0, 0, 0, 0) written.append(f"{key_s}=default") elif act_s in MOUSE_ACTIONS: code = MOUSE_ACTIONS[act_s] records[idx] = (0x01, 0x01, code, 0) written.append(f"{key_s}={canon[code]}") elif act_s in EDITOR_ACTIONS: # шорткат редактора: [02, модификатор(Ctrl), HID-код, 00] code = EDITOR_ACTIONS[act_s] records[idx] = (0x02, 0x01, code, 0) written.append(f"{key_s}={act_s}") elif act_s in MULTIMEDIA_ACTIONS: # мультимедиа/веб: [03, usage u16 LE Consumer Page, 00] usage = MULTIMEDIA_ACTIONS[act_s] records[idx] = (0x03, usage & 0xFF, usage >> 8, 0) written.append(f"{key_s}={act_s}") elif parse_hotkey(act_s) is not None: # горячая клавиша: [02, маска_модификаторов, HID-код, 00] # (дамп keybind-hotkeys: A, Shift+B, Ctrl+C, Alt+D, Meta+E, ...) mods, code = parse_hotkey(act_s) records[idx] = (0x02, mods, code, 0) written.append(f"{key_s}={act_s.lower()}") elif act_s.startswith("macro"): action = parse_macro_action(act_s) if action is None: raise SystemExit( f"remap: bad macro action {act_s!r} " "(expected macro, macro:K or macro-toggle)") midx, mode, count = action records[idx] = (0x06, midx, mode, count) label = f"macro{midx}" if mode == 0x01: label += f":{count}" elif mode == 0x02: label += "-toggle" written.append(f"{key_s}={label}") else: raise SystemExit( f"remap: unknown action {act_s!r} " f"(actions: {'|'.join(MOUSE_ACTIONS)}, " f"{'|'.join(EDITOR_ACTIONS)}, " f"{'|'.join(MULTIMEDIA_ACTIONS)}, " "hotkey MOD+KEY (ctrl|shift|alt|meta + a..z|0..9|f1..f12|" "esc|tab|enter|space|caps|backspace|num0..num9|fn), " "default, " "macro, macro:K, macro-toggle)") k.write_bind_table(records, save=not args.no_save) if written: print(f"bind table written: {', '.join(written)}") else: print("bind table written: all keys reset to default") def cmd_macro(k: Katana, args): """Содержимое макросов: запись в прошивку (04 19), разбор/генерация XML.""" if args.macro_cmd == "set": if not 0 <= args.index < MACRO_HEADER_LEN // 4: raise SystemExit(f"macro set: index {args.index} out of range " f"(0..{MACRO_HEADER_LEN // 4 - 1})") if args.xml: if args.tokens: raise SystemExit("macro set: use either --xml or tokens, not both") events = parse_macro_xml(args.xml) else: if not args.tokens: raise SystemExit("macro set: provide tokens or --xml FILE") try: events = parse_macro_tokens(args.tokens) except ValueError as e: raise SystemExit(f"macro set: {e}") # Все слоты в один блоб: слот index = events, прочие — пустые. # Запись одного слота ОБНУЛЯЕТ прочие (проверено железом), поэтому # несколько макросов нужно писать за один вызов — apply так и делает. slots = [[]] * args.index + [events] try: blob_len = len(build_macro_blob(slots)) except ValueError as e: raise SystemExit(f"macro set: {e}") k.write_macro_table(slots, save=args.save) print(f"macro {args.index} written: {len(events)} events, " f"blob {blob_len} bytes, save={'yes' if args.save else 'no'} " f"(all other macro slots erased)") print(f"bind it: ./katana.py remap --key KEY=macro{args.index}") # Запись макросов сбивает bind-таблицу и режим подсветки в RAM # (проверено железом) — после макросов перепошлите remap и mode # (apply делает это автоматически). elif args.macro_cmd == "clear": k.write_macro_table([], save=args.save) print("all macro slots erased") elif args.macro_cmd == "show": events = parse_macro_xml(args.file) print(f"{args.file}: {len(events)} events") for event in events: print(f" {macro_event_str(event)}") elif args.macro_cmd == "export": try: events = parse_macro_tokens(args.tokens) except ValueError as e: raise SystemExit(f"macro export: {e}") try: xml = macro_to_xml(events, name=args.name) except ValueError as e: raise SystemExit(f"macro export: {e}") with open(args.file, "w", encoding="utf-8") as f: f.write(xml) print(f"{len(events)} events written to {args.file}") if __name__ == "__main__": main()