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/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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ kovarctl = kovarctl.cli:main
@@ -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.