Files

1887 lines
97 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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<N>.
Примеры:
./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 <opcode> и вернуть 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("<H", ack, 4)[0]
if got != expected:
print(f"warning: checksum ack={got:#06x} != calc={expected:#06x}",
file=sys.stderr)
# -- команды --
def lighting(self, payload: bytes, save: bool = True):
"""Полная транзакция: begin → data-begin → payload → commit → save."""
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)
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(
"<I", MACRO_HEADER_LEN + len(data))
data += struct.pack("<II", 2 * len(events), 0)
for code, etype, delay in events:
if not 0 <= delay <= 0xFFFF:
raise ValueError(f"delay {delay} ms does not fit u16")
data += bytes([0, 0, code, etype,
delay & 0xFF, delay >> 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 = ['<?xml version="1.0" encoding="UTF-8"?>', "<macro>",
f'<macroinfo name="{name}"/>', "<macroitems>"]
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'<item desc="{desc}" type="{itype}" value="{value}" '
f'delay_time="{delay}"/>')
lines += ["</macroitems>", "</macro>"]
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<N>, macro<N>:K, macro<N>-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<N>:K — повторить K раз
return idx, 0x01, int(m.group(2))
if spec.endswith("-toggle"): # macro<N>-toggle — до останова
return idx, 0x02, 0
return idx, 0x00, 0 # macro<N> — однократно
# ---- 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<N>|macro<N>:K|"
"macro<N>-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<N>, macro<N>:K or macro<N>-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<N>, macro<N>:K, macro<N>-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()