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/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Control the lighting of KOVAR keyboards (ROYUAN YC500 firmware)."""
|
kovarctl/__main__.py
ADDED
kovarctl/cli.py
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
"""Command line interface for KOVAR keyboards."""
|
|
2
|
+
|
|
3
|
+
import colorsys
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from . import protocol
|
|
11
|
+
from .device import DONGLE_PRODUCT_IDS, Keyboard, KeyboardError, find_vendor_interfaces
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Context:
|
|
15
|
+
def __init__(self, device, model_id, dry_run, force, verbose):
|
|
16
|
+
self.device = device
|
|
17
|
+
self.model_id = model_id
|
|
18
|
+
self.dry_run = dry_run
|
|
19
|
+
self.force = force
|
|
20
|
+
self.verbose = verbose
|
|
21
|
+
|
|
22
|
+
def log(self, message):
|
|
23
|
+
if self.verbose or self.dry_run:
|
|
24
|
+
click.echo(message, err=True)
|
|
25
|
+
|
|
26
|
+
def open(self, probe=True):
|
|
27
|
+
if self.device:
|
|
28
|
+
paths = [self.device]
|
|
29
|
+
else:
|
|
30
|
+
paths = find_vendor_interfaces()
|
|
31
|
+
if not paths:
|
|
32
|
+
raise click.ClickException("no KOVAR keyboard found (3151:4010); dongles are not supported yet")
|
|
33
|
+
|
|
34
|
+
keyboards = []
|
|
35
|
+
for path in paths:
|
|
36
|
+
try:
|
|
37
|
+
keyboard = Keyboard(path, self.dry_run, self.force, self.log, probe=probe)
|
|
38
|
+
except KeyboardError as error:
|
|
39
|
+
if len(paths) == 1:
|
|
40
|
+
raise click.ClickException(str(error))
|
|
41
|
+
continue
|
|
42
|
+
if self.model_id is None or keyboard.device_id == self.model_id:
|
|
43
|
+
keyboards.append(keyboard)
|
|
44
|
+
else:
|
|
45
|
+
keyboard.close()
|
|
46
|
+
|
|
47
|
+
if len(keyboards) != 1:
|
|
48
|
+
for keyboard in keyboards:
|
|
49
|
+
keyboard.close()
|
|
50
|
+
raise click.ClickException(
|
|
51
|
+
"no matching keyboard" if not keyboards else
|
|
52
|
+
"several keyboards found, select one with --device or --id (see 'kovarctl list')")
|
|
53
|
+
|
|
54
|
+
keyboard = keyboards[0]
|
|
55
|
+
if probe and keyboard.model is None:
|
|
56
|
+
keyboard.close()
|
|
57
|
+
raise click.ClickException(f"unknown model ID {keyboard.device_id}")
|
|
58
|
+
return keyboard
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
pass_context = click.make_pass_decorator(Context)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Group(click.Group):
|
|
65
|
+
"""Report device errors as normal command errors."""
|
|
66
|
+
|
|
67
|
+
def invoke(self, ctx):
|
|
68
|
+
try:
|
|
69
|
+
return super().invoke(ctx)
|
|
70
|
+
except KeyboardError as error:
|
|
71
|
+
raise click.ClickException(str(error))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def color_argument(ctx, param, value):
|
|
75
|
+
if value is None:
|
|
76
|
+
return None
|
|
77
|
+
try:
|
|
78
|
+
return protocol.parse_color(value)
|
|
79
|
+
except ValueError as error:
|
|
80
|
+
raise click.BadParameter(str(error))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def describe(state, side=False):
|
|
84
|
+
effect = protocol.EFFECTS_BY_VALUE.get(state.effect)
|
|
85
|
+
name = effect.name if effect else f"unknown (0x{state.effect:02X})"
|
|
86
|
+
parts = [name]
|
|
87
|
+
if effect is None or effect.speed:
|
|
88
|
+
parts.append(f"speed {state.speed}")
|
|
89
|
+
parts.append(f"brightness {state.brightness}")
|
|
90
|
+
if effect is None or effect.color:
|
|
91
|
+
parts.append("rainbow" if state.rainbow else f"color {protocol.format_color(state.effective_color)}")
|
|
92
|
+
if effect and effect.directions and not side:
|
|
93
|
+
parts.append(f"direction {effect.directions[state.variant % len(effect.directions)]}")
|
|
94
|
+
return ", ".join(parts)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@click.group(cls=Group)
|
|
98
|
+
@click.option("--device", metavar="PATH", help="hidraw node of the keyboard's vendor interface.")
|
|
99
|
+
@click.option("--id", "model_id", type=int, help="Select the keyboard by firmware model ID.")
|
|
100
|
+
@click.option("--dry-run", is_flag=True, help="Read from the keyboard, but only print what would be written.")
|
|
101
|
+
@click.option("--force", is_flag=True, help="Ignore the flash write rate limit.")
|
|
102
|
+
@click.option("-v", "--verbose", is_flag=True, help="Report flash writes.")
|
|
103
|
+
@click.version_option(package_name="kovarctl")
|
|
104
|
+
@click.pass_context
|
|
105
|
+
def main(ctx, device, model_id, dry_run, force, verbose):
|
|
106
|
+
"""Control the lighting of KOVAR keyboards (ROYUAN YC500 firmware).
|
|
107
|
+
|
|
108
|
+
Effect settings and custom pictures are stored in the keyboard flash.
|
|
109
|
+
kovarctl skips writes that change nothing and limits how often flash is
|
|
110
|
+
written. Use 'live' for frequently changing colors.
|
|
111
|
+
"""
|
|
112
|
+
ctx.obj = Context(device, model_id, dry_run, force, verbose)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@main.command("list")
|
|
116
|
+
def list_keyboards():
|
|
117
|
+
"""List connected keyboards."""
|
|
118
|
+
for path in find_vendor_interfaces():
|
|
119
|
+
try:
|
|
120
|
+
with Keyboard(path) as keyboard:
|
|
121
|
+
name = keyboard.model.name if keyboard.model else "unknown model"
|
|
122
|
+
click.echo(f"{path} ID {keyboard.device_id:<4} {name} (firmware {keyboard.revision()})")
|
|
123
|
+
except (KeyboardError, OSError) as error:
|
|
124
|
+
click.echo(f"{path} {error}")
|
|
125
|
+
for path in find_vendor_interfaces(DONGLE_PRODUCT_IDS):
|
|
126
|
+
click.echo(f"{path} wireless dongle (not supported yet, use the USB cable)")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@main.command()
|
|
130
|
+
@pass_context
|
|
131
|
+
def info(obj):
|
|
132
|
+
"""Show model, firmware and current lighting."""
|
|
133
|
+
with obj.open() as keyboard:
|
|
134
|
+
click.echo(f"Model: {keyboard.model.name} (ID {keyboard.device_id})")
|
|
135
|
+
click.echo(f"Firmware: {keyboard.revision()}")
|
|
136
|
+
click.echo(f"Device: {keyboard.path}")
|
|
137
|
+
click.echo(f"Keys: {describe(keyboard.light_state())}")
|
|
138
|
+
if keyboard.model.side_light:
|
|
139
|
+
click.echo(f"Side: {describe(keyboard.light_state(side=True), side=True)}")
|
|
140
|
+
picture = keyboard.picture()
|
|
141
|
+
lit = sum(1 for slot in keyboard.model.keys.values() if any(picture[slot * 3:slot * 3 + 3]))
|
|
142
|
+
click.echo(f"Custom: {lit} of {len(keyboard.model.keys)} keys lit")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@main.command()
|
|
146
|
+
def effects():
|
|
147
|
+
"""List effects and their options."""
|
|
148
|
+
for effect in protocol.EFFECTS:
|
|
149
|
+
options = []
|
|
150
|
+
if effect.speed:
|
|
151
|
+
options.append("speed")
|
|
152
|
+
if effect.color:
|
|
153
|
+
options.append("color/rainbow")
|
|
154
|
+
if effect.directions:
|
|
155
|
+
options.append("direction: " + "/".join(effect.directions))
|
|
156
|
+
side = " (also side light)" if effect in protocol.SIDE_EFFECTS else ""
|
|
157
|
+
click.echo(f"{effect.name:15} {', '.join(options)}{side}")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@main.command()
|
|
161
|
+
@pass_context
|
|
162
|
+
def keys(obj):
|
|
163
|
+
"""List the key names of the connected keyboard."""
|
|
164
|
+
with obj.open() as keyboard:
|
|
165
|
+
click.echo(" ".join(keyboard.model.keys))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def light_options(command):
|
|
169
|
+
command = click.option("--speed", type=click.IntRange(0, protocol.SPEED_MAX), help="0 (slow) to 4 (fast).")(command)
|
|
170
|
+
command = click.option("--brightness", type=click.IntRange(0, protocol.BRIGHTNESS_MAX), help="0 to 4.")(command)
|
|
171
|
+
command = click.option("--color", callback=color_argument, help="RRGGBB or a color name.")(command)
|
|
172
|
+
command = click.option("--rainbow", is_flag=True, help="Use changing colors instead of one color.")(command)
|
|
173
|
+
return command
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def apply_light(keyboard, effect, speed, brightness, color, rainbow, direction=None, side=False):
|
|
177
|
+
current = keyboard.light_state(side=side)
|
|
178
|
+
same_effect = current.effect == effect.value
|
|
179
|
+
|
|
180
|
+
speed = speed if speed is not None else (current.speed if same_effect else 2)
|
|
181
|
+
brightness = brightness if brightness is not None else current.brightness or protocol.BRIGHTNESS_MAX
|
|
182
|
+
|
|
183
|
+
if effect.value == protocol.EFFECT_OFF:
|
|
184
|
+
speed, brightness, option, color = 0, 0, protocol.OPTION_USER_COLOR, (0, 0, 0)
|
|
185
|
+
elif effect.value == protocol.EFFECT_CUSTOM:
|
|
186
|
+
option, color = protocol.OPTION_CUSTOM_SLOT_1, (0x00, 0xC8, 0xC8)
|
|
187
|
+
elif not effect.color:
|
|
188
|
+
option, color = protocol.OPTION_USER_COLOR, (0, 0, 0)
|
|
189
|
+
else:
|
|
190
|
+
if rainbow:
|
|
191
|
+
option = protocol.OPTION_RAINBOW
|
|
192
|
+
elif color is not None:
|
|
193
|
+
option = protocol.OPTION_USER_COLOR
|
|
194
|
+
else:
|
|
195
|
+
option = protocol.OPTION_RAINBOW if current.rainbow else protocol.OPTION_USER_COLOR
|
|
196
|
+
color = color or current.effective_color
|
|
197
|
+
|
|
198
|
+
if effect.directions:
|
|
199
|
+
variant = effect.directions.index(direction) if direction else (current.variant if same_effect else 0)
|
|
200
|
+
option |= variant << 4
|
|
201
|
+
elif direction:
|
|
202
|
+
raise click.BadParameter(f"{effect.name} has no direction", param_hint="--direction")
|
|
203
|
+
|
|
204
|
+
if not effect.speed:
|
|
205
|
+
speed = 0
|
|
206
|
+
|
|
207
|
+
return keyboard.set_light(effect.value, speed, brightness, option, color, side=side)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@main.command()
|
|
211
|
+
@click.argument("effect", type=click.Choice([effect.name for effect in protocol.EFFECTS if effect.name != "live"]))
|
|
212
|
+
@light_options
|
|
213
|
+
@click.option("--direction", type=click.Choice(["left", "right", "up", "down", "clockwise", "anticlockwise"]))
|
|
214
|
+
@pass_context
|
|
215
|
+
def mode(obj, effect, speed, brightness, color, rainbow, direction):
|
|
216
|
+
"""Set the effect of the keys (stored in flash).
|
|
217
|
+
|
|
218
|
+
Options that are not given keep their current value.
|
|
219
|
+
"""
|
|
220
|
+
effect = protocol.EFFECTS_BY_NAME[effect]
|
|
221
|
+
if direction and effect.directions and direction not in effect.directions:
|
|
222
|
+
raise click.BadParameter(f"{effect.name} supports {', '.join(effect.directions)}", param_hint="--direction")
|
|
223
|
+
with obj.open() as keyboard:
|
|
224
|
+
changed = apply_light(keyboard, effect, speed, brightness, color, rainbow, direction)
|
|
225
|
+
if not changed:
|
|
226
|
+
click.echo("Keys: unchanged")
|
|
227
|
+
elif not obj.dry_run:
|
|
228
|
+
click.echo(f"Keys: {describe(keyboard.light_state())}")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@main.command()
|
|
232
|
+
@pass_context
|
|
233
|
+
def off(obj):
|
|
234
|
+
"""Turn the key lighting off (stored in flash)."""
|
|
235
|
+
with obj.open() as keyboard:
|
|
236
|
+
apply_light(keyboard, protocol.EFFECTS_BY_NAME["off"], None, None, None, False)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@main.command()
|
|
240
|
+
@click.argument("effect", type=click.Choice([effect.name for effect in protocol.SIDE_EFFECTS]))
|
|
241
|
+
@light_options
|
|
242
|
+
@pass_context
|
|
243
|
+
def side(obj, effect, speed, brightness, color, rainbow):
|
|
244
|
+
"""Set the side light (stored in flash)."""
|
|
245
|
+
with obj.open() as keyboard:
|
|
246
|
+
if not keyboard.model.side_light:
|
|
247
|
+
raise click.ClickException(f"{keyboard.model.name} has no known side light")
|
|
248
|
+
changed = apply_light(keyboard, protocol.EFFECTS_BY_NAME[effect], speed, brightness, color, rainbow, side=True)
|
|
249
|
+
if changed and not obj.dry_run:
|
|
250
|
+
click.echo(f"Side: {describe(keyboard.light_state(side=True), side=True)}")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@main.command()
|
|
254
|
+
@click.argument("color", required=False, callback=color_argument)
|
|
255
|
+
@click.option("--fade", type=float, metavar="SECONDS", help="Cycle through all hues for this long.")
|
|
256
|
+
@click.option("--stdin", "from_stdin", is_flag=True, help="Read one color per line from standard input.")
|
|
257
|
+
@pass_context
|
|
258
|
+
def live(obj, color, fade, from_stdin):
|
|
259
|
+
"""Set one color for all keys, without writing flash.
|
|
260
|
+
|
|
261
|
+
Switching into live mode is one flash write the first time; after that,
|
|
262
|
+
colors can change as often as wanted. The color is lost when the
|
|
263
|
+
keyboard is unplugged.
|
|
264
|
+
"""
|
|
265
|
+
if sum(option is not None and option is not False for option in (color, fade, from_stdin)) != 1:
|
|
266
|
+
raise click.UsageError("give exactly one of COLOR, --fade or --stdin")
|
|
267
|
+
|
|
268
|
+
with obj.open() as keyboard:
|
|
269
|
+
keyboard.set_light(protocol.EFFECT_LIVE, 0, protocol.BRIGHTNESS_MAX, protocol.OPTION_USER_COLOR, (0, 0, 0))
|
|
270
|
+
|
|
271
|
+
if color is not None:
|
|
272
|
+
keyboard.live_color(color)
|
|
273
|
+
elif fade is not None:
|
|
274
|
+
start = time.time()
|
|
275
|
+
while time.time() - start < fade:
|
|
276
|
+
hue = ((time.time() - start) / 5.0) % 1.0
|
|
277
|
+
keyboard.live_color(tuple(int(c * 255) for c in colorsys.hsv_to_rgb(hue, 1.0, 1.0)))
|
|
278
|
+
time.sleep(1 / 30)
|
|
279
|
+
else:
|
|
280
|
+
for line in sys.stdin:
|
|
281
|
+
if line.strip():
|
|
282
|
+
try:
|
|
283
|
+
keyboard.live_color(protocol.parse_color(line))
|
|
284
|
+
except ValueError as error:
|
|
285
|
+
click.echo(f"skipped: {error}", err=True)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@main.group()
|
|
289
|
+
def custom():
|
|
290
|
+
"""Per-key colors of the custom effect (stored in flash)."""
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def picture_colors(keyboard, picture):
|
|
294
|
+
return {key: tuple(picture[slot * 3:slot * 3 + 3]) for key, slot in keyboard.model.keys.items()}
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def write_picture(obj, keyboard, picture, changed_slots, activate):
|
|
298
|
+
if changed_slots and len(changed_slots) <= 3:
|
|
299
|
+
for slot in changed_slots:
|
|
300
|
+
keyboard.set_picture_key(slot, tuple(picture[slot * 3:slot * 3 + 3]))
|
|
301
|
+
elif changed_slots:
|
|
302
|
+
keyboard.set_picture(picture)
|
|
303
|
+
else:
|
|
304
|
+
obj.log("picture unchanged, nothing written")
|
|
305
|
+
|
|
306
|
+
if activate:
|
|
307
|
+
apply_light(keyboard, protocol.EFFECTS_BY_NAME["custom"], None, None, None, False)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@custom.command("show")
|
|
311
|
+
@click.option("--all", "show_all", is_flag=True, help="Also show keys that are off.")
|
|
312
|
+
@click.option("--json", "as_json", is_flag=True, help="Print JSON (the format 'custom load' reads).")
|
|
313
|
+
@pass_context
|
|
314
|
+
def custom_show(obj, show_all, as_json):
|
|
315
|
+
"""Show the stored custom picture."""
|
|
316
|
+
with obj.open() as keyboard:
|
|
317
|
+
colors = picture_colors(keyboard, keyboard.picture())
|
|
318
|
+
if as_json:
|
|
319
|
+
click.echo(json.dumps({key: protocol.format_color(color) for key, color in colors.items()}, indent=2))
|
|
320
|
+
return
|
|
321
|
+
for key, color in colors.items():
|
|
322
|
+
if show_all or any(color):
|
|
323
|
+
click.echo(f"{key:20} {protocol.format_color(color)}")
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
@custom.command("set")
|
|
327
|
+
@click.argument("assignments", nargs=-1, required=True, metavar="KEY=COLOR...")
|
|
328
|
+
@click.option("--activate", is_flag=True, help="Also switch the keys to the custom effect.")
|
|
329
|
+
@pass_context
|
|
330
|
+
def custom_set(obj, assignments, activate):
|
|
331
|
+
"""Change keys of the custom picture, e.g. 'escape=red w=00ff00'.
|
|
332
|
+
|
|
333
|
+
The key 'all' sets every key. 'kovarctl keys' lists the key names.
|
|
334
|
+
"""
|
|
335
|
+
with obj.open() as keyboard:
|
|
336
|
+
picture = keyboard.picture()
|
|
337
|
+
changed = set()
|
|
338
|
+
for assignment in assignments:
|
|
339
|
+
key, _, value = assignment.partition("=")
|
|
340
|
+
try:
|
|
341
|
+
color = protocol.parse_color(value)
|
|
342
|
+
except ValueError as error:
|
|
343
|
+
raise click.BadParameter(str(error), param_hint=assignment)
|
|
344
|
+
if key == "all":
|
|
345
|
+
slots = list(keyboard.model.keys.values())
|
|
346
|
+
elif key in keyboard.model.keys:
|
|
347
|
+
slots = [keyboard.model.keys[key]]
|
|
348
|
+
else:
|
|
349
|
+
raise click.BadParameter(f"unknown key {key!r}, see 'kovarctl keys'", param_hint=assignment)
|
|
350
|
+
for slot in slots:
|
|
351
|
+
if tuple(picture[slot * 3:slot * 3 + 3]) != color:
|
|
352
|
+
picture[slot * 3:slot * 3 + 3] = bytes(color)
|
|
353
|
+
changed.add(slot)
|
|
354
|
+
write_picture(obj, keyboard, picture, sorted(changed), activate)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
@custom.command("save")
|
|
358
|
+
@click.argument("file", type=click.File("w"))
|
|
359
|
+
@pass_context
|
|
360
|
+
def custom_save(obj, file):
|
|
361
|
+
"""Save the custom picture as JSON."""
|
|
362
|
+
with obj.open() as keyboard:
|
|
363
|
+
colors = picture_colors(keyboard, keyboard.picture())
|
|
364
|
+
json.dump({key: protocol.format_color(color) for key, color in colors.items()}, file, indent=2)
|
|
365
|
+
file.write("\n")
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
@custom.command("load")
|
|
369
|
+
@click.argument("file", type=click.File("r"))
|
|
370
|
+
@click.option("--activate", is_flag=True, help="Also switch the keys to the custom effect.")
|
|
371
|
+
@pass_context
|
|
372
|
+
def custom_load(obj, file, activate):
|
|
373
|
+
"""Load a custom picture from JSON ({"escape": "#FF0000", ...}).
|
|
374
|
+
|
|
375
|
+
Keys missing in the file keep their color.
|
|
376
|
+
"""
|
|
377
|
+
try:
|
|
378
|
+
data = json.load(file)
|
|
379
|
+
except ValueError as error:
|
|
380
|
+
raise click.ClickException(f"{file.name}: {error}")
|
|
381
|
+
with obj.open() as keyboard:
|
|
382
|
+
picture = keyboard.picture()
|
|
383
|
+
changed = set()
|
|
384
|
+
for key, value in data.items():
|
|
385
|
+
if key not in keyboard.model.keys:
|
|
386
|
+
raise click.ClickException(f"{file.name}: unknown key {key!r}")
|
|
387
|
+
slot = keyboard.model.keys[key]
|
|
388
|
+
color = protocol.parse_color(value)
|
|
389
|
+
if tuple(picture[slot * 3:slot * 3 + 3]) != color:
|
|
390
|
+
picture[slot * 3:slot * 3 + 3] = bytes(color)
|
|
391
|
+
changed.add(slot)
|
|
392
|
+
write_picture(obj, keyboard, picture, sorted(changed), activate)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
@main.command()
|
|
396
|
+
@pass_context
|
|
397
|
+
def recover(obj):
|
|
398
|
+
"""Reset the keyboard if its lighting interface stopped answering."""
|
|
399
|
+
with obj.open(probe=False) as keyboard:
|
|
400
|
+
if obj.dry_run:
|
|
401
|
+
click.echo(f"would reset the keyboard at {keyboard.path}")
|
|
402
|
+
return
|
|
403
|
+
keyboard.reset()
|
|
404
|
+
time.sleep(3)
|
|
405
|
+
paths = find_vendor_interfaces()
|
|
406
|
+
for path in paths:
|
|
407
|
+
try:
|
|
408
|
+
with Keyboard(path) as keyboard:
|
|
409
|
+
click.echo(f"{path}: answers again, model ID {keyboard.device_id}")
|
|
410
|
+
except KeyboardError as error:
|
|
411
|
+
click.echo(f"{path}: {error}")
|
kovarctl/device.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Access to KOVAR keyboards through Linux hidraw.
|
|
2
|
+
|
|
3
|
+
Safety rules learned on real hardware:
|
|
4
|
+
- Only one program may talk to the vendor interface at a time. A lock file
|
|
5
|
+
guards kovarctl instances, and other programs (e.g. OpenRGB) holding the
|
|
6
|
+
hidraw node open are detected.
|
|
7
|
+
- After a flash write the firmware must not receive anything for a while.
|
|
8
|
+
The end of that busy time is stored on disk, so separate kovarctl calls
|
|
9
|
+
respect it too.
|
|
10
|
+
- Flash writes are rate limited across calls to protect the flash.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import fcntl
|
|
14
|
+
import glob
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import time
|
|
18
|
+
|
|
19
|
+
from . import protocol
|
|
20
|
+
|
|
21
|
+
VENDOR_ID = 0x3151
|
|
22
|
+
PRODUCT_ID = 0x4010
|
|
23
|
+
DONGLE_PRODUCT_IDS = (0x4011, 0x4014)
|
|
24
|
+
VENDOR_INTERFACE = "input2"
|
|
25
|
+
|
|
26
|
+
FLASH_WRITE_LIMIT = 12
|
|
27
|
+
FLASH_WRITE_WINDOW = 60.0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _ioc(direction, nr, size):
|
|
31
|
+
return (direction << 30) | (size << 16) | (ord("H") << 8) | nr
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
HIDIOCSFEATURE = _ioc(3, 0x06, protocol.REPORT_LENGTH + 1)
|
|
35
|
+
HIDIOCGFEATURE = _ioc(3, 0x07, protocol.REPORT_LENGTH + 1)
|
|
36
|
+
USBDEVFS_RESET = (ord("U") << 8) | 20
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class KeyboardError(Exception):
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class FlashGuardError(KeyboardError):
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _hid_id(hidraw):
|
|
48
|
+
try:
|
|
49
|
+
uevent = open(f"/sys/class/hidraw/{hidraw}/device/uevent").read()
|
|
50
|
+
except OSError:
|
|
51
|
+
return None, None, None
|
|
52
|
+
fields = dict(line.split("=", 1) for line in uevent.splitlines() if "=" in line)
|
|
53
|
+
_, vid, pid = fields.get("HID_ID", "0:0:0").split(":")
|
|
54
|
+
return int(vid, 16), int(pid, 16), fields.get("HID_PHYS", "")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def find_vendor_interfaces(product_ids=(PRODUCT_ID,)):
|
|
58
|
+
"""Return /dev/hidrawN paths of the vendor interfaces of matching devices."""
|
|
59
|
+
paths = []
|
|
60
|
+
for node in sorted(glob.glob("/sys/class/hidraw/hidraw*"), key=lambda p: int(p.rsplit("hidraw", 1)[1])):
|
|
61
|
+
hidraw = os.path.basename(node)
|
|
62
|
+
vid, pid, phys = _hid_id(hidraw)
|
|
63
|
+
if vid == VENDOR_ID and pid in product_ids and phys.endswith(VENDOR_INTERFACE):
|
|
64
|
+
paths.append("/dev/" + hidraw)
|
|
65
|
+
return paths
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def other_users(path):
|
|
69
|
+
"""Names of other processes that have the given device node open."""
|
|
70
|
+
users = []
|
|
71
|
+
for fd_dir in glob.glob("/proc/[0-9]*/fd"):
|
|
72
|
+
pid = fd_dir.split("/")[2]
|
|
73
|
+
if int(pid) == os.getpid():
|
|
74
|
+
continue
|
|
75
|
+
try:
|
|
76
|
+
for fd in os.listdir(fd_dir):
|
|
77
|
+
if os.readlink(os.path.join(fd_dir, fd)) == path:
|
|
78
|
+
users.append(f"{open(f'/proc/{pid}/comm').read().strip()} ({pid})")
|
|
79
|
+
break
|
|
80
|
+
except OSError:
|
|
81
|
+
continue
|
|
82
|
+
return users
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _state_dir():
|
|
86
|
+
base = os.environ.get("XDG_RUNTIME_DIR") or f"/tmp/kovarctl-{os.getuid()}"
|
|
87
|
+
path = os.path.join(base, "kovarctl")
|
|
88
|
+
os.makedirs(path, mode=0o700, exist_ok=True)
|
|
89
|
+
return path
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class Keyboard:
|
|
93
|
+
"""One KOVAR keyboard, opened through its vendor hidraw interface."""
|
|
94
|
+
|
|
95
|
+
def __init__(self, path, dry_run=False, force=False, log=None, probe=True):
|
|
96
|
+
vid, pid, phys = _hid_id(os.path.basename(path))
|
|
97
|
+
if vid != VENDOR_ID or pid != PRODUCT_ID or not phys.endswith(VENDOR_INTERFACE):
|
|
98
|
+
raise KeyboardError(f"{path} is not the vendor interface of a 3151:4010 keyboard")
|
|
99
|
+
|
|
100
|
+
self.path = path
|
|
101
|
+
self.dry_run = dry_run
|
|
102
|
+
self.force = force
|
|
103
|
+
self.log = log or (lambda message: None)
|
|
104
|
+
self.usb_port = phys.split("/")[0]
|
|
105
|
+
|
|
106
|
+
name = self.usb_port.replace("/", "_").replace(":", "_")
|
|
107
|
+
self._lock_file = open(os.path.join(_state_dir(), name + ".lock"), "w")
|
|
108
|
+
self._state_path = os.path.join(_state_dir(), name + ".json")
|
|
109
|
+
try:
|
|
110
|
+
fcntl.flock(self._lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
111
|
+
except BlockingIOError:
|
|
112
|
+
raise KeyboardError("another kovarctl is using this keyboard") from None
|
|
113
|
+
|
|
114
|
+
users = other_users(path)
|
|
115
|
+
if users:
|
|
116
|
+
raise KeyboardError(f"{path} is in use by {', '.join(users)}; close it first (e.g. OpenRGB)")
|
|
117
|
+
|
|
118
|
+
self._fd = os.open(path, os.O_RDWR)
|
|
119
|
+
self.device_id = None
|
|
120
|
+
self.model = None
|
|
121
|
+
|
|
122
|
+
if probe:
|
|
123
|
+
try:
|
|
124
|
+
self.device_id = protocol.parse_device_id(self.query(protocol.CMD_GET_DEVICE_INFO))
|
|
125
|
+
except (KeyboardError, ValueError) as error:
|
|
126
|
+
self.close()
|
|
127
|
+
raise KeyboardError(f"{path} does not answer ({error}); try 'kovarctl recover'") from None
|
|
128
|
+
self.model = protocol.MODELS.get(self.device_id)
|
|
129
|
+
|
|
130
|
+
def close(self):
|
|
131
|
+
os.close(self._fd)
|
|
132
|
+
self._lock_file.close()
|
|
133
|
+
|
|
134
|
+
def __enter__(self):
|
|
135
|
+
return self
|
|
136
|
+
|
|
137
|
+
def __exit__(self, *exc):
|
|
138
|
+
self.close()
|
|
139
|
+
|
|
140
|
+
# Persistent state shared between calls
|
|
141
|
+
|
|
142
|
+
def _load_state(self):
|
|
143
|
+
try:
|
|
144
|
+
with open(self._state_path) as state_file:
|
|
145
|
+
return json.load(state_file)
|
|
146
|
+
except (OSError, ValueError):
|
|
147
|
+
return {"busy_until": 0.0, "flash_writes": []}
|
|
148
|
+
|
|
149
|
+
def _save_state(self, state):
|
|
150
|
+
with open(self._state_path, "w") as state_file:
|
|
151
|
+
json.dump(state, state_file)
|
|
152
|
+
|
|
153
|
+
def wait_idle(self):
|
|
154
|
+
remaining = self._load_state()["busy_until"] - time.time()
|
|
155
|
+
if remaining > 0:
|
|
156
|
+
time.sleep(remaining)
|
|
157
|
+
|
|
158
|
+
# Raw transfers
|
|
159
|
+
|
|
160
|
+
def _send(self, data):
|
|
161
|
+
report = b"\0" + bytes(data).ljust(protocol.REPORT_LENGTH, b"\0")
|
|
162
|
+
try:
|
|
163
|
+
fcntl.ioctl(self._fd, HIDIOCSFEATURE, report)
|
|
164
|
+
except OSError as error:
|
|
165
|
+
raise KeyboardError(f"sending to {self.path} failed ({error}); "
|
|
166
|
+
"if the keyboard stopped answering, run 'kovarctl recover'") from None
|
|
167
|
+
|
|
168
|
+
def _receive(self):
|
|
169
|
+
report = bytearray(protocol.REPORT_LENGTH + 1)
|
|
170
|
+
try:
|
|
171
|
+
fcntl.ioctl(self._fd, HIDIOCGFEATURE, report)
|
|
172
|
+
except OSError as error:
|
|
173
|
+
raise KeyboardError(f"reading from {self.path} failed ({error}); "
|
|
174
|
+
"if the keyboard stopped answering, run 'kovarctl recover'") from None
|
|
175
|
+
return bytes(report[1:])
|
|
176
|
+
|
|
177
|
+
def query(self, command, *arguments, wait=0.02):
|
|
178
|
+
"""Send a read request and return the 64 byte response."""
|
|
179
|
+
self.wait_idle()
|
|
180
|
+
self._send(protocol.payload(command, *arguments))
|
|
181
|
+
time.sleep(wait)
|
|
182
|
+
return self._receive()
|
|
183
|
+
|
|
184
|
+
# Reading
|
|
185
|
+
|
|
186
|
+
def revision(self):
|
|
187
|
+
response = self.query(protocol.CMD_GET_REV)
|
|
188
|
+
return f"{response[2]}.{response[1]:02d}"
|
|
189
|
+
|
|
190
|
+
def light_state(self, side=False):
|
|
191
|
+
command = protocol.CMD_GET_SIDE_PARAMS if side else protocol.CMD_GET_LED_PARAMS
|
|
192
|
+
return protocol.parse_led_params(self.query(command), side=side)
|
|
193
|
+
|
|
194
|
+
def picture(self):
|
|
195
|
+
data = b"".join(self.query(protocol.CMD_GET_PICTURE, 0x00, page) for page in range(protocol.PICTURE_FRAMES))
|
|
196
|
+
return bytearray(data[:protocol.PICTURE_SIZE])
|
|
197
|
+
|
|
198
|
+
# Writing
|
|
199
|
+
|
|
200
|
+
def flash_write(self, packets, busy, what):
|
|
201
|
+
"""Send packets that end in a flash write, respecting busy time and rate limit."""
|
|
202
|
+
state = self._load_state()
|
|
203
|
+
now = time.time()
|
|
204
|
+
recent = [stamp for stamp in state["flash_writes"] if now - stamp < FLASH_WRITE_WINDOW]
|
|
205
|
+
|
|
206
|
+
if len(recent) >= FLASH_WRITE_LIMIT and not self.force:
|
|
207
|
+
raise FlashGuardError(
|
|
208
|
+
f"{len(recent)} flash writes in the last {FLASH_WRITE_WINDOW:.0f} s; refusing to write more "
|
|
209
|
+
"to protect the keyboard flash (use --force to override, or 'live' for fast changes)")
|
|
210
|
+
|
|
211
|
+
if self.dry_run:
|
|
212
|
+
for packet in packets:
|
|
213
|
+
self.log(f"would send {packet[:16].hex(' ')} ...")
|
|
214
|
+
return
|
|
215
|
+
|
|
216
|
+
self.wait_idle()
|
|
217
|
+
for index, packet in enumerate(packets):
|
|
218
|
+
if index > 0:
|
|
219
|
+
time.sleep(protocol.PICTURE_FRAME_DELAY)
|
|
220
|
+
self._send(packet)
|
|
221
|
+
|
|
222
|
+
state["busy_until"] = time.time() + busy
|
|
223
|
+
state["flash_writes"] = recent + [time.time()]
|
|
224
|
+
self._save_state(state)
|
|
225
|
+
self.log(f"flash write: {what}")
|
|
226
|
+
|
|
227
|
+
def set_light(self, effect, speed, brightness, option, color, side=False):
|
|
228
|
+
"""Write LED or side light parameters unless the keyboard already has them."""
|
|
229
|
+
packet = protocol.led_params(effect, speed, brightness, option, color, side=side)
|
|
230
|
+
if protocol.state_bytes(self.light_state(side=side), side=side) == packet[:8]:
|
|
231
|
+
self.log("unchanged, nothing written")
|
|
232
|
+
return False
|
|
233
|
+
self.flash_write([packet], protocol.BUSY_LED_PARAMS, "side light" if side else "LED parameters")
|
|
234
|
+
return True
|
|
235
|
+
|
|
236
|
+
def set_picture(self, picture):
|
|
237
|
+
if bytes(self.picture()) == bytes(picture):
|
|
238
|
+
self.log("picture unchanged, nothing written")
|
|
239
|
+
return False
|
|
240
|
+
self.flash_write(protocol.picture_frames(picture), protocol.BUSY_PICTURE, "custom picture")
|
|
241
|
+
return True
|
|
242
|
+
|
|
243
|
+
def set_picture_key(self, slot, color):
|
|
244
|
+
self.flash_write([protocol.picture_key(slot, color)], protocol.BUSY_PICTURE_KEY, f"custom key slot {slot}")
|
|
245
|
+
|
|
246
|
+
def live_color(self, color):
|
|
247
|
+
"""RAM only, safe to send often."""
|
|
248
|
+
if self.dry_run:
|
|
249
|
+
self.log(f"would send {protocol.live_color(color)[:8].hex(' ')}")
|
|
250
|
+
return
|
|
251
|
+
self.wait_idle()
|
|
252
|
+
self._send(protocol.live_color(color))
|
|
253
|
+
|
|
254
|
+
def reset(self):
|
|
255
|
+
"""USB reset of the keyboard, recovers a locked up vendor interface."""
|
|
256
|
+
usb_device = os.path.realpath(f"/sys/class/hidraw/{os.path.basename(self.path)}/device/../..")
|
|
257
|
+
bus = int(open(os.path.join(usb_device, "busnum")).read())
|
|
258
|
+
device = int(open(os.path.join(usb_device, "devnum")).read())
|
|
259
|
+
fd = os.open(f"/dev/bus/usb/{bus:03d}/{device:03d}", os.O_WRONLY)
|
|
260
|
+
try:
|
|
261
|
+
fcntl.ioctl(fd, USBDEVFS_RESET, 0)
|
|
262
|
+
finally:
|
|
263
|
+
os.close(fd)
|