1887 lines
97 KiB
Python
Executable File
1887 lines
97 KiB
Python
Executable File
#!/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()
|