kovarctl 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kovarctl/__init__.py +1 -0
- kovarctl/__main__.py +3 -0
- kovarctl/cli.py +411 -0
- kovarctl/device.py +263 -0
- kovarctl/layouts.py +273 -0
- kovarctl/protocol.py +247 -0
- kovarctl-0.1.0.dist-info/METADATA +142 -0
- kovarctl-0.1.0.dist-info/RECORD +12 -0
- kovarctl-0.1.0.dist-info/WHEEL +5 -0
- kovarctl-0.1.0.dist-info/entry_points.txt +2 -0
- kovarctl-0.1.0.dist-info/licenses/LICENSE +21 -0
- kovarctl-0.1.0.dist-info/top_level.txt +1 -0
kovarctl/layouts.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""Key name to firmware matrix slot maps.
|
|
2
|
+
|
|
3
|
+
The firmware stores the keys in a matrix of 6 rows, column by column,
|
|
4
|
+
so slot = column * 6 + row."""
|
|
5
|
+
|
|
6
|
+
FULL_KEYS = {
|
|
7
|
+
'escape': 0,
|
|
8
|
+
'back_tick': 1,
|
|
9
|
+
'tab': 2,
|
|
10
|
+
'caps_lock': 3,
|
|
11
|
+
'left_shift': 4,
|
|
12
|
+
'left_control': 5,
|
|
13
|
+
'1': 7,
|
|
14
|
+
'q': 8,
|
|
15
|
+
'a': 9,
|
|
16
|
+
'iso_back_slash': 10,
|
|
17
|
+
'f1': 12,
|
|
18
|
+
'2': 13,
|
|
19
|
+
'w': 14,
|
|
20
|
+
's': 15,
|
|
21
|
+
'z': 16,
|
|
22
|
+
'left_windows': 17,
|
|
23
|
+
'f2': 18,
|
|
24
|
+
'3': 19,
|
|
25
|
+
'e': 20,
|
|
26
|
+
'd': 21,
|
|
27
|
+
'x': 22,
|
|
28
|
+
'left_alt': 23,
|
|
29
|
+
'f3': 24,
|
|
30
|
+
'4': 25,
|
|
31
|
+
'r': 26,
|
|
32
|
+
'f': 27,
|
|
33
|
+
'c': 28,
|
|
34
|
+
'f4': 30,
|
|
35
|
+
'5': 31,
|
|
36
|
+
't': 32,
|
|
37
|
+
'g': 33,
|
|
38
|
+
'v': 34,
|
|
39
|
+
'f5': 36,
|
|
40
|
+
'6': 37,
|
|
41
|
+
'y': 38,
|
|
42
|
+
'h': 39,
|
|
43
|
+
'b': 40,
|
|
44
|
+
'space': 41,
|
|
45
|
+
'f6': 42,
|
|
46
|
+
'7': 43,
|
|
47
|
+
'u': 44,
|
|
48
|
+
'j': 45,
|
|
49
|
+
'n': 46,
|
|
50
|
+
'right_alt': 47,
|
|
51
|
+
'f7': 48,
|
|
52
|
+
'8': 49,
|
|
53
|
+
'i': 50,
|
|
54
|
+
'k': 51,
|
|
55
|
+
'm': 52,
|
|
56
|
+
'right_function': 53,
|
|
57
|
+
'f8': 54,
|
|
58
|
+
'9': 55,
|
|
59
|
+
'o': 56,
|
|
60
|
+
'l': 57,
|
|
61
|
+
'comma': 58,
|
|
62
|
+
'menu': 59,
|
|
63
|
+
'f9': 60,
|
|
64
|
+
'0': 61,
|
|
65
|
+
'p': 62,
|
|
66
|
+
'semicolon': 63,
|
|
67
|
+
'period': 64,
|
|
68
|
+
'right_control': 65,
|
|
69
|
+
'f10': 66,
|
|
70
|
+
'minus': 67,
|
|
71
|
+
'left_bracket': 68,
|
|
72
|
+
'quote': 69,
|
|
73
|
+
'forward_slash': 70,
|
|
74
|
+
'left_arrow': 71,
|
|
75
|
+
'f11': 72,
|
|
76
|
+
'equals': 73,
|
|
77
|
+
'right_bracket': 74,
|
|
78
|
+
'right_shift': 76,
|
|
79
|
+
'down_arrow': 77,
|
|
80
|
+
'f12': 78,
|
|
81
|
+
'backspace': 79,
|
|
82
|
+
'pound': 80,
|
|
83
|
+
'ansi_enter': 81,
|
|
84
|
+
'up_arrow': 82,
|
|
85
|
+
'right_arrow': 83,
|
|
86
|
+
'print_screen': 84,
|
|
87
|
+
'insert': 85,
|
|
88
|
+
'delete': 86,
|
|
89
|
+
'numpad_7': 87,
|
|
90
|
+
'numpad_4': 88,
|
|
91
|
+
'numpad_1': 89,
|
|
92
|
+
'scroll_lock': 90,
|
|
93
|
+
'home': 91,
|
|
94
|
+
'end': 92,
|
|
95
|
+
'numpad_8': 93,
|
|
96
|
+
'numpad_5': 94,
|
|
97
|
+
'numpad_2': 95,
|
|
98
|
+
'pause_break': 96,
|
|
99
|
+
'page_up': 97,
|
|
100
|
+
'page_down': 98,
|
|
101
|
+
'numpad_9': 99,
|
|
102
|
+
'numpad_6': 100,
|
|
103
|
+
'numpad_3': 101,
|
|
104
|
+
'calculator': 102,
|
|
105
|
+
'volume_up': 103,
|
|
106
|
+
'numpad_lock': 104,
|
|
107
|
+
'numpad_times': 105,
|
|
108
|
+
'numpad_period': 106,
|
|
109
|
+
'numpad_0': 107,
|
|
110
|
+
'volume_down': 108,
|
|
111
|
+
'mute': 109,
|
|
112
|
+
'numpad_divide': 110,
|
|
113
|
+
'numpad_minus': 111,
|
|
114
|
+
'numpad_plus': 112,
|
|
115
|
+
'numpad_enter': 113,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
TKL_KEYS = {
|
|
119
|
+
'escape': 0,
|
|
120
|
+
'back_tick': 1,
|
|
121
|
+
'tab': 2,
|
|
122
|
+
'caps_lock': 3,
|
|
123
|
+
'left_shift': 4,
|
|
124
|
+
'left_control': 5,
|
|
125
|
+
'1': 7,
|
|
126
|
+
'q': 8,
|
|
127
|
+
'a': 9,
|
|
128
|
+
'iso_back_slash': 10,
|
|
129
|
+
'f1': 12,
|
|
130
|
+
'2': 13,
|
|
131
|
+
'w': 14,
|
|
132
|
+
's': 15,
|
|
133
|
+
'z': 16,
|
|
134
|
+
'left_windows': 17,
|
|
135
|
+
'f2': 18,
|
|
136
|
+
'3': 19,
|
|
137
|
+
'e': 20,
|
|
138
|
+
'd': 21,
|
|
139
|
+
'x': 22,
|
|
140
|
+
'left_alt': 23,
|
|
141
|
+
'f3': 24,
|
|
142
|
+
'4': 25,
|
|
143
|
+
'r': 26,
|
|
144
|
+
'f': 27,
|
|
145
|
+
'c': 28,
|
|
146
|
+
'f4': 30,
|
|
147
|
+
'5': 31,
|
|
148
|
+
't': 32,
|
|
149
|
+
'g': 33,
|
|
150
|
+
'v': 34,
|
|
151
|
+
'f5': 36,
|
|
152
|
+
'6': 37,
|
|
153
|
+
'y': 38,
|
|
154
|
+
'h': 39,
|
|
155
|
+
'b': 40,
|
|
156
|
+
'space': 41,
|
|
157
|
+
'f6': 42,
|
|
158
|
+
'7': 43,
|
|
159
|
+
'u': 44,
|
|
160
|
+
'j': 45,
|
|
161
|
+
'n': 46,
|
|
162
|
+
'right_alt': 47,
|
|
163
|
+
'f7': 48,
|
|
164
|
+
'8': 49,
|
|
165
|
+
'i': 50,
|
|
166
|
+
'k': 51,
|
|
167
|
+
'm': 52,
|
|
168
|
+
'right_function': 53,
|
|
169
|
+
'f8': 54,
|
|
170
|
+
'9': 55,
|
|
171
|
+
'o': 56,
|
|
172
|
+
'l': 57,
|
|
173
|
+
'comma': 58,
|
|
174
|
+
'menu': 59,
|
|
175
|
+
'f9': 60,
|
|
176
|
+
'0': 61,
|
|
177
|
+
'p': 62,
|
|
178
|
+
'semicolon': 63,
|
|
179
|
+
'period': 64,
|
|
180
|
+
'right_control': 65,
|
|
181
|
+
'f10': 66,
|
|
182
|
+
'minus': 67,
|
|
183
|
+
'left_bracket': 68,
|
|
184
|
+
'quote': 69,
|
|
185
|
+
'forward_slash': 70,
|
|
186
|
+
'left_arrow': 71,
|
|
187
|
+
'f11': 72,
|
|
188
|
+
'equals': 73,
|
|
189
|
+
'right_bracket': 74,
|
|
190
|
+
'right_shift': 76,
|
|
191
|
+
'down_arrow': 77,
|
|
192
|
+
'f12': 78,
|
|
193
|
+
'backspace': 79,
|
|
194
|
+
'pound': 80,
|
|
195
|
+
'ansi_enter': 81,
|
|
196
|
+
'up_arrow': 82,
|
|
197
|
+
'right_arrow': 83,
|
|
198
|
+
'print_screen': 84,
|
|
199
|
+
'insert': 85,
|
|
200
|
+
'delete': 86,
|
|
201
|
+
'scroll_lock': 90,
|
|
202
|
+
'home': 91,
|
|
203
|
+
'end': 92,
|
|
204
|
+
'pause_break': 96,
|
|
205
|
+
'page_up': 97,
|
|
206
|
+
'page_down': 98,
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
SIXTY_KEYS = {
|
|
210
|
+
'escape': 1,
|
|
211
|
+
'tab': 2,
|
|
212
|
+
'caps_lock': 3,
|
|
213
|
+
'left_shift': 4,
|
|
214
|
+
'left_control': 5,
|
|
215
|
+
'1': 7,
|
|
216
|
+
'q': 8,
|
|
217
|
+
'a': 9,
|
|
218
|
+
'iso_back_slash': 10,
|
|
219
|
+
'2': 13,
|
|
220
|
+
'w': 14,
|
|
221
|
+
's': 15,
|
|
222
|
+
'z': 16,
|
|
223
|
+
'left_windows': 17,
|
|
224
|
+
'3': 19,
|
|
225
|
+
'e': 20,
|
|
226
|
+
'd': 21,
|
|
227
|
+
'x': 22,
|
|
228
|
+
'left_alt': 23,
|
|
229
|
+
'4': 25,
|
|
230
|
+
'r': 26,
|
|
231
|
+
'f': 27,
|
|
232
|
+
'c': 28,
|
|
233
|
+
'5': 31,
|
|
234
|
+
't': 32,
|
|
235
|
+
'g': 33,
|
|
236
|
+
'v': 34,
|
|
237
|
+
'6': 37,
|
|
238
|
+
'y': 38,
|
|
239
|
+
'h': 39,
|
|
240
|
+
'b': 40,
|
|
241
|
+
'space': 41,
|
|
242
|
+
'7': 43,
|
|
243
|
+
'u': 44,
|
|
244
|
+
'j': 45,
|
|
245
|
+
'n': 46,
|
|
246
|
+
'8': 49,
|
|
247
|
+
'i': 50,
|
|
248
|
+
'k': 51,
|
|
249
|
+
'm': 52,
|
|
250
|
+
'9': 55,
|
|
251
|
+
'o': 56,
|
|
252
|
+
'l': 57,
|
|
253
|
+
'comma': 58,
|
|
254
|
+
'0': 61,
|
|
255
|
+
'p': 62,
|
|
256
|
+
'semicolon': 63,
|
|
257
|
+
'period': 64,
|
|
258
|
+
'right_alt': 65,
|
|
259
|
+
'minus': 67,
|
|
260
|
+
'left_bracket': 68,
|
|
261
|
+
'quote': 69,
|
|
262
|
+
'forward_slash': 70,
|
|
263
|
+
'menu': 71,
|
|
264
|
+
'equals': 73,
|
|
265
|
+
'right_bracket': 74,
|
|
266
|
+
'right_shift': 76,
|
|
267
|
+
'right_control': 77,
|
|
268
|
+
'backspace': 79,
|
|
269
|
+
'pound': 80,
|
|
270
|
+
'ansi_enter': 81,
|
|
271
|
+
'right_function': 83,
|
|
272
|
+
}
|
|
273
|
+
|
kovarctl/protocol.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""ROYUAN YC500 lighting protocol (KOVAR keyboards).
|
|
2
|
+
|
|
3
|
+
Everything here is pure: it builds and parses 64 byte feature report
|
|
4
|
+
payloads but never talks to a device. Byte 0 of a payload is the command.
|
|
5
|
+
|
|
6
|
+
Most commands carry a checksum in byte 7 (0xFF minus the sum of bytes
|
|
7
|
+
0-6). The LED parameter commands carry it in byte 8 (over bytes 0-7).
|
|
8
|
+
|
|
9
|
+
Flash: LED parameters (0x07), side light parameters (0x08), the custom
|
|
10
|
+
picture (0x0C) and single custom keys (0x14) are stored in flash. The
|
|
11
|
+
firmware keeps the USB interface busy afterwards and locks up if it
|
|
12
|
+
receives anything during that time. The live color (0x0E) is RAM only.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
from . import layouts
|
|
18
|
+
|
|
19
|
+
REPORT_LENGTH = 64
|
|
20
|
+
|
|
21
|
+
CMD_GET_REV = 0x80
|
|
22
|
+
CMD_GET_DEVICE_INFO = 0x8F
|
|
23
|
+
CMD_SET_LED_PARAMS = 0x07
|
|
24
|
+
CMD_GET_LED_PARAMS = 0x87
|
|
25
|
+
CMD_SET_SIDE_PARAMS = 0x08
|
|
26
|
+
CMD_GET_SIDE_PARAMS = 0x88
|
|
27
|
+
CMD_SET_PICTURE = 0x0C
|
|
28
|
+
CMD_GET_PICTURE = 0x8C
|
|
29
|
+
CMD_SET_LIVE_COLOR = 0x0E
|
|
30
|
+
CMD_SET_PICTURE_KEY = 0x14
|
|
31
|
+
|
|
32
|
+
# Measured on a KOVAR and a KOVAR Wireless (seconds of USB silence needed)
|
|
33
|
+
BUSY_LED_PARAMS = 1.0
|
|
34
|
+
BUSY_PICTURE = 1.4
|
|
35
|
+
BUSY_PICTURE_KEY = 0.15
|
|
36
|
+
|
|
37
|
+
PICTURE_SLOTS = 128
|
|
38
|
+
PICTURE_SIZE = PICTURE_SLOTS * 3
|
|
39
|
+
PICTURE_FRAMES = 7
|
|
40
|
+
PICTURE_FRAME_SIZE = 56
|
|
41
|
+
PICTURE_PAGE_SIZE = 64
|
|
42
|
+
PICTURE_FRAME_DELAY = 0.02
|
|
43
|
+
|
|
44
|
+
SPEED_MAX = 4
|
|
45
|
+
BRIGHTNESS_MAX = 4
|
|
46
|
+
|
|
47
|
+
OPTION_USER_COLOR = 0x07
|
|
48
|
+
OPTION_RAINBOW = 0x08
|
|
49
|
+
OPTION_CUSTOM_SLOT_1 = 0x00
|
|
50
|
+
|
|
51
|
+
EFFECT_OFF = 0x00
|
|
52
|
+
EFFECT_CUSTOM = 0x0D
|
|
53
|
+
EFFECT_LIVE = 0x15
|
|
54
|
+
|
|
55
|
+
# The firmware does not take pure white as a user color
|
|
56
|
+
WHITE = (0xFF, 0xFF, 0xFF)
|
|
57
|
+
WHITE_SUBSTITUTE = (0xFA, 0xFF, 0xFA)
|
|
58
|
+
|
|
59
|
+
PRESET_COLORS = [
|
|
60
|
+
(0xFF, 0x00, 0x00), (0xFF, 0x80, 0x00), (0xFF, 0xFF, 0x00), (0x00, 0xFF, 0x00),
|
|
61
|
+
(0x00, 0xFF, 0xFF), (0x00, 0x00, 0xFF), (0xFF, 0x00, 0xFF),
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class Effect:
|
|
67
|
+
name: str
|
|
68
|
+
value: int
|
|
69
|
+
speed: bool = True
|
|
70
|
+
color: bool = True
|
|
71
|
+
# Direction names in the order of their option value (high nibble)
|
|
72
|
+
directions: tuple = ()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
EFFECTS = [
|
|
76
|
+
Effect("off", EFFECT_OFF, speed=False, color=False),
|
|
77
|
+
Effect("static", 0x01, speed=False),
|
|
78
|
+
Effect("breathing", 0x02),
|
|
79
|
+
Effect("spectrum-cycle", 0x03, color=False),
|
|
80
|
+
Effect("wave", 0x04, directions=("right", "left", "down", "up")),
|
|
81
|
+
Effect("ripple", 0x05),
|
|
82
|
+
Effect("raindrop", 0x06),
|
|
83
|
+
Effect("snake", 0x07),
|
|
84
|
+
Effect("reactive", 0x08),
|
|
85
|
+
Effect("converge", 0x09),
|
|
86
|
+
Effect("sine-wave", 0x0A),
|
|
87
|
+
Effect("kaleidoscope", 0x0B),
|
|
88
|
+
Effect("line-wave", 0x0C, directions=("right", "left")),
|
|
89
|
+
Effect("custom", EFFECT_CUSTOM, speed=False, color=False),
|
|
90
|
+
Effect("laser", 0x0E),
|
|
91
|
+
Effect("circle-wave", 0x0F, directions=("anticlockwise", "clockwise")),
|
|
92
|
+
Effect("dazzling", 0x10),
|
|
93
|
+
Effect("rain-down", 0x11),
|
|
94
|
+
Effect("meteor", 0x12),
|
|
95
|
+
Effect("reactive-off", 0x13),
|
|
96
|
+
Effect("live", EFFECT_LIVE, speed=False, color=False),
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
SIDE_EFFECTS = [effect for effect in EFFECTS if effect.value in (0x00, 0x01, 0x02, 0x03)]
|
|
100
|
+
|
|
101
|
+
EFFECTS_BY_NAME = {effect.name: effect for effect in EFFECTS}
|
|
102
|
+
EFFECTS_BY_VALUE = {effect.value: effect for effect in EFFECTS}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class Model:
|
|
107
|
+
device_id: int
|
|
108
|
+
name: str
|
|
109
|
+
keys: dict
|
|
110
|
+
side_light: bool
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
MODELS = {
|
|
114
|
+
738: Model(738, "KOVAR", layouts.FULL_KEYS, True),
|
|
115
|
+
723: Model(723, "KOVAR Wireless", layouts.FULL_KEYS, True),
|
|
116
|
+
737: Model(737, "KOVAR TKL", layouts.TKL_KEYS, False),
|
|
117
|
+
736: Model(736, "KOVAR 60", layouts.SIXTY_KEYS, False),
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True)
|
|
122
|
+
class LightState:
|
|
123
|
+
"""Decoded LED or side light parameters. Speed 0 is slowest."""
|
|
124
|
+
|
|
125
|
+
effect: int
|
|
126
|
+
speed: int
|
|
127
|
+
brightness: int
|
|
128
|
+
option: int
|
|
129
|
+
color: tuple
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def rainbow(self):
|
|
133
|
+
return (self.option & 0x0F) == OPTION_RAINBOW
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def variant(self):
|
|
137
|
+
return self.option >> 4
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def effective_color(self):
|
|
141
|
+
preset = self.option & 0x0F
|
|
142
|
+
if preset < len(PRESET_COLORS):
|
|
143
|
+
return PRESET_COLORS[preset]
|
|
144
|
+
if self.color == WHITE_SUBSTITUTE:
|
|
145
|
+
return WHITE
|
|
146
|
+
return self.color
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def checksum(data):
|
|
150
|
+
return (0xFF - (sum(data) & 0xFF)) & 0xFF
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def payload(*values, checksum_at=7):
|
|
154
|
+
"""Build a payload from leading bytes and add the checksum."""
|
|
155
|
+
buffer = bytearray(REPORT_LENGTH)
|
|
156
|
+
buffer[:len(values)] = bytes(values)
|
|
157
|
+
if checksum_at is not None:
|
|
158
|
+
buffer[checksum_at] = checksum(buffer[:checksum_at])
|
|
159
|
+
return bytes(buffer)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def query(command):
|
|
163
|
+
return payload(command)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def led_params(effect, speed, brightness, option, color, side=False):
|
|
167
|
+
"""SET_LEDPARAM (keys) or SET_SLEDPARAM (side light).
|
|
168
|
+
|
|
169
|
+
The keys take the speed reversed (0 is fastest), the side light does not.
|
|
170
|
+
"""
|
|
171
|
+
if not 0 <= speed <= SPEED_MAX or not 0 <= brightness <= BRIGHTNESS_MAX:
|
|
172
|
+
raise ValueError("speed and brightness must be between 0 and 4")
|
|
173
|
+
if tuple(color) == WHITE:
|
|
174
|
+
color = WHITE_SUBSTITUTE
|
|
175
|
+
raw_speed = speed if side else SPEED_MAX - speed
|
|
176
|
+
command = CMD_SET_SIDE_PARAMS if side else CMD_SET_LED_PARAMS
|
|
177
|
+
return payload(command, effect, raw_speed, brightness, option, *color, checksum_at=8)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def parse_led_params(response, side=False):
|
|
181
|
+
"""Decode a GET_LEDPARAM / GET_SLEDPARAM response (64 bytes, command first)."""
|
|
182
|
+
expected = CMD_GET_SIDE_PARAMS if side else CMD_GET_LED_PARAMS
|
|
183
|
+
if response[0] != expected:
|
|
184
|
+
raise ValueError(f"unexpected response 0x{response[0]:02X}")
|
|
185
|
+
raw_speed = min(response[2], SPEED_MAX)
|
|
186
|
+
speed = raw_speed if side else SPEED_MAX - raw_speed
|
|
187
|
+
return LightState(response[1], speed, min(response[3], BRIGHTNESS_MAX), response[4], tuple(response[5:8]))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def state_bytes(state, side=False):
|
|
191
|
+
"""The bytes of a SET payload that matter when comparing with a read back state."""
|
|
192
|
+
return led_params(state.effect, state.speed, state.brightness, state.option, state.color, side)[:8]
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def live_color(color):
|
|
196
|
+
return payload(CMD_SET_LIVE_COLOR, *color)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def picture_frames(picture):
|
|
200
|
+
"""SET_USERPIC frames for a full 384 byte picture."""
|
|
201
|
+
if len(picture) != PICTURE_SIZE:
|
|
202
|
+
raise ValueError(f"picture must be {PICTURE_SIZE} bytes")
|
|
203
|
+
data = bytes(picture).ljust(PICTURE_FRAMES * PICTURE_FRAME_SIZE, b"\0")
|
|
204
|
+
frames = []
|
|
205
|
+
for frame in range(PICTURE_FRAMES):
|
|
206
|
+
header = payload(CMD_SET_PICTURE, 0x00, PICTURE_SIZE & 0xFF, PICTURE_SIZE >> 8, frame)
|
|
207
|
+
chunk = data[frame * PICTURE_FRAME_SIZE:(frame + 1) * PICTURE_FRAME_SIZE]
|
|
208
|
+
frames.append(header[:8] + chunk)
|
|
209
|
+
return frames
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def picture_page_request(page):
|
|
213
|
+
return payload(CMD_GET_PICTURE, 0x00, page)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def picture_key(slot, color, layer=0):
|
|
217
|
+
"""SET_USERPIC_SIMPLE: one key of the custom picture."""
|
|
218
|
+
buffer = bytearray(payload(CMD_SET_PICTURE_KEY, layer, slot))
|
|
219
|
+
buffer[8:11] = bytes(color)
|
|
220
|
+
return bytes(buffer)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def parse_device_id(response):
|
|
224
|
+
if response[0] != CMD_GET_DEVICE_INFO:
|
|
225
|
+
raise ValueError(f"unexpected response 0x{response[0]:02X}")
|
|
226
|
+
return int.from_bytes(response[1:5], "little")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def parse_color(text):
|
|
230
|
+
"""Parse 'ff8000', '#FF8000' or a few names into an (r, g, b) tuple."""
|
|
231
|
+
names = {
|
|
232
|
+
"black": (0, 0, 0), "off": (0, 0, 0), "white": WHITE, "red": (255, 0, 0),
|
|
233
|
+
"green": (0, 255, 0), "blue": (0, 0, 255), "yellow": (255, 255, 0),
|
|
234
|
+
"cyan": (0, 255, 255), "magenta": (255, 0, 255), "orange": (255, 128, 0),
|
|
235
|
+
}
|
|
236
|
+
text = text.strip().lower()
|
|
237
|
+
if text in names:
|
|
238
|
+
return names[text]
|
|
239
|
+
text = text.removeprefix("#").removeprefix("0x")
|
|
240
|
+
if len(text) != 6:
|
|
241
|
+
raise ValueError(f"not a color: {text!r}")
|
|
242
|
+
value = int(text, 16)
|
|
243
|
+
return (value >> 16, (value >> 8) & 0xFF, value & 0xFF)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def format_color(color):
|
|
247
|
+
return "#{:02X}{:02X}{:02X}".format(*color)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kovarctl
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Control the lighting of KOVAR keyboards (ROYUAN YC500 firmware) on Linux
|
|
5
|
+
Author-email: Lars Kiesow <lkiesow@uos.de>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/lkiesow/kovarctl
|
|
8
|
+
Project-URL: Issues, https://github.com/lkiesow/kovarctl/issues
|
|
9
|
+
Keywords: kovar,keyboard,rgb,lighting,hidraw
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: System :: Hardware
|
|
15
|
+
Requires-Python: >=3.13
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: click>=8
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# kovarctl
|
|
22
|
+
|
|
23
|
+
Command line tool for the lighting of KOVAR keyboards (ROYUAN YC500 firmware, USB ID `3151:4010`) on Linux.
|
|
24
|
+
|
|
25
|
+
Supported models, identified by the firmware model ID:
|
|
26
|
+
|
|
27
|
+
| ID | Model | Tested |
|
|
28
|
+
|-----|-----------------------------|--------|
|
|
29
|
+
| 738 | KOVAR | yes |
|
|
30
|
+
| 723 | KOVAR Wireless (USB cable) | yes |
|
|
31
|
+
| 737 | KOVAR TKL | no |
|
|
32
|
+
| 736 | KOVAR 60 | no |
|
|
33
|
+
|
|
34
|
+
The 2.4 GHz dongle (`3151:4011`) is not supported, because it uses a different protocol. Connect the keyboard with a USB cable instead.
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
pipx install kovarctl
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`pip install --user kovarctl` and `uv tool install kovarctl` work as well.
|
|
43
|
+
|
|
44
|
+
The only dependency is [click](https://click.palletsprojects.com/). Your user needs access to the keyboard's `/dev/hidraw*` nodes. Desktop sessions usually grant it, and the OpenRGB udev rules do too.
|
|
45
|
+
|
|
46
|
+
## Usage
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
kovarctl list # connected keyboards
|
|
50
|
+
kovarctl info # model, firmware, current lighting
|
|
51
|
+
kovarctl effects # effects and their options
|
|
52
|
+
kovarctl mode wave --direction left --color 00ffff
|
|
53
|
+
kovarctl mode breathing --speed 3 # options not given stay as they are
|
|
54
|
+
kovarctl mode static --rainbow
|
|
55
|
+
kovarctl off
|
|
56
|
+
kovarctl side breathing --color red # side light
|
|
57
|
+
kovarctl live ff8000 # one color for all keys, no flash write
|
|
58
|
+
kovarctl live --fade 30 # rainbow fade for 30 s
|
|
59
|
+
some-program | kovarctl live --stdin # one color per line
|
|
60
|
+
kovarctl keys # key names
|
|
61
|
+
kovarctl custom set escape=red w=green --activate
|
|
62
|
+
kovarctl custom set all=000040
|
|
63
|
+
kovarctl custom save picture.json
|
|
64
|
+
kovarctl custom load picture.json --activate
|
|
65
|
+
kovarctl recover # if the keyboard stopped answering
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Use `--dry-run` to see what would be written, `-v` to report flash writes, and `--device` or `--id` to choose a keyboard when several are connected.
|
|
69
|
+
|
|
70
|
+
## Shell completion
|
|
71
|
+
|
|
72
|
+
kovarctl can complete commands, effect names and option values such as `--direction`. This needs the installed `kovarctl` command; it does not work with `python3 -m kovarctl`.
|
|
73
|
+
|
|
74
|
+
**Bash** (version 4.4 or newer), add to `~/.bashrc`:
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
eval "$(_KOVARCTL_COMPLETE=bash_source kovarctl)"
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
**Zsh**, add to `~/.zshrc` after `compinit` has run (frameworks like Oh My Zsh run it for you):
|
|
81
|
+
|
|
82
|
+
```sh
|
|
83
|
+
eval "$(_KOVARCTL_COMPLETE=zsh_source kovarctl)"
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
**Fish**, add to `~/.config/fish/config.fish`:
|
|
87
|
+
|
|
88
|
+
```sh
|
|
89
|
+
_KOVARCTL_COMPLETE=fish_source kovarctl | source
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Open a new shell afterwards. The completion is generated each time a shell starts (about 30 ms), so it always matches the installed version.
|
|
93
|
+
|
|
94
|
+
## Keyboard flash
|
|
95
|
+
|
|
96
|
+
The keyboard stores effect settings, the side light and the custom picture in its flash memory. Each write keeps the firmware busy for up to 1.4 seconds. During that time the keyboard ignores requests and can even lock up its lighting interface. kovarctl protects the keyboard in four ways:
|
|
97
|
+
|
|
98
|
+
- It reads the current state first and skips writes that change nothing.
|
|
99
|
+
- It waits until the keyboard is idle before sending anything, including across separate kovarctl calls.
|
|
100
|
+
- It refuses more than 12 flash writes per minute unless you pass `--force`.
|
|
101
|
+
- It refuses to run while another program, such as OpenRGB, has the keyboard open.
|
|
102
|
+
|
|
103
|
+
For colors that change often, use `live`. Only switching into live mode is a flash write; after that, colors can change as often as you like.
|
|
104
|
+
|
|
105
|
+
The blue LED between Esc and F1 cannot be controlled.
|
|
106
|
+
|
|
107
|
+
## Development
|
|
108
|
+
|
|
109
|
+
Install from a checkout of the repository and run the tests:
|
|
110
|
+
|
|
111
|
+
```sh
|
|
112
|
+
pip install -e . pytest
|
|
113
|
+
python3 -m pytest
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## License
|
|
117
|
+
|
|
118
|
+
MIT, see [LICENSE](https://github.com/lkiesow/kovarctl/blob/main/LICENSE).
|
|
119
|
+
|
|
120
|
+
## Releasing
|
|
121
|
+
|
|
122
|
+
1. Set the new version in `pyproject.toml`, commit and tag it:
|
|
123
|
+
|
|
124
|
+
```sh
|
|
125
|
+
git commit -am 'Release 0.2.0'
|
|
126
|
+
git tag v0.2.0
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
2. Build the packages and check them, in a virtual environment:
|
|
130
|
+
|
|
131
|
+
```sh
|
|
132
|
+
rm -rf dist/
|
|
133
|
+
python3 -m build
|
|
134
|
+
twine check --strict dist/*
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
3. Upload to [PyPI](https://pypi.org/project/kovarctl/) and push the tag:
|
|
138
|
+
|
|
139
|
+
```sh
|
|
140
|
+
twine upload dist/*
|
|
141
|
+
git push origin main v0.2.0
|
|
142
|
+
```
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
kovarctl/__init__.py,sha256=XMnlKhwCwCPfyzMtlCERcvBQA5fBMP2Lg6lUiaJF-r0,71
|
|
2
|
+
kovarctl/__main__.py,sha256=bYt9eEaoRQWdejEHFD8REx9jxVEdZptECFsV7F49Ink,30
|
|
3
|
+
kovarctl/cli.py,sha256=h-8hPyM1zYjiNFo_Aw8Bxb3DRK1iK6-vC9I6h55ur8w,16263
|
|
4
|
+
kovarctl/device.py,sha256=S16FK-Var86Z4yaVa-VqIgpEIML6B_4UdvfSq2eL5lM,9655
|
|
5
|
+
kovarctl/layouts.py,sha256=8_nFxLSufyfz80WFdc2FTQwVgcGZ4nFmTnTbDIyJ_go,4570
|
|
6
|
+
kovarctl/protocol.py,sha256=qWy7KV4EdzPloCWlbGZGof2QZ-Y0fguMPagNbNuqvXY,7658
|
|
7
|
+
kovarctl-0.1.0.dist-info/licenses/LICENSE,sha256=4guhcrbie02tYRBGzNZfHykrZ__5Pglwh4hcFgGoPpA,1068
|
|
8
|
+
kovarctl-0.1.0.dist-info/METADATA,sha256=Rv8GPpJRcYXiEcvVfjodE3xOMuT96jYs3R30atgLNjM,5054
|
|
9
|
+
kovarctl-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
10
|
+
kovarctl-0.1.0.dist-info/entry_points.txt,sha256=QG2Xb3x-KpgY2SkR8SCTkUYiLzkZFw05bsdCr4CuulU,47
|
|
11
|
+
kovarctl-0.1.0.dist-info/top_level.txt,sha256=qvghpK7hRjuXCXy_fEkF0-Bql4e70ax7UaZL-oJ_Qas,9
|
|
12
|
+
kovarctl-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lars Kiesow
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|