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 ADDED
@@ -0,0 +1 @@
1
+ """Control the lighting of KOVAR keyboards (ROYUAN YC500 firmware)."""
kovarctl/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
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)