m693ctl 1.3.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.
m693/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Userspace driver for the Redragon M693 wireless gaming mouse."""
2
+ from .device import Device, DeviceError, find_devices, find_receivers
3
+ from .profile import Profile
4
+
5
+ __all__ = ["Device", "DeviceError", "find_devices", "find_receivers", "Profile"]
6
+ __version__ = "1.3.0"
m693/cli.py ADDED
@@ -0,0 +1,690 @@
1
+ """Command line front-end for the Redragon M693."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ import time
9
+
10
+ from . import __version__
11
+ from . import keys as K
12
+ from . import lighting as L
13
+ from . import macro as MACRO
14
+ from . import presets as PRESETS
15
+ from . import profile as P
16
+ from . import transfer
17
+ from .device import (
18
+ PAIR_LISTENING,
19
+ Device,
20
+ DeviceError,
21
+ find_devices,
22
+ find_receivers,
23
+ )
24
+
25
+
26
+ def _color(text: str) -> tuple[int, int, int]:
27
+ text = text.lstrip("#")
28
+ if len(text) != 6:
29
+ raise argparse.ArgumentTypeError("colour must be RRGGBB hex")
30
+ return (int(text[0:2], 16), int(text[2:4], 16), int(text[4:6], 16))
31
+
32
+
33
+ def _wait(text: str) -> float | None:
34
+ """Seconds to keep retrying, or None for indefinitely."""
35
+ if text.lower() in ("forever", "inf", "infinite"):
36
+ return None
37
+ try:
38
+ seconds = float(text)
39
+ except ValueError:
40
+ raise argparse.ArgumentTypeError("--wait takes seconds or 'forever'")
41
+ if seconds < 0:
42
+ raise argparse.ArgumentTypeError("--wait cannot be negative")
43
+ return seconds
44
+
45
+
46
+ def _onoff(text: str) -> bool:
47
+ if text.lower() in ("on", "true", "1", "yes"):
48
+ return True
49
+ if text.lower() in ("off", "false", "0", "no"):
50
+ return False
51
+ raise argparse.ArgumentTypeError("expected on/off")
52
+
53
+
54
+ # --------------------------------------------------------------- subcommands
55
+ def cmd_list(args) -> int:
56
+ nodes = find_devices()
57
+ if not nodes:
58
+ print("No Redragon M693 found.")
59
+ return 1
60
+ for node in nodes:
61
+ print(node)
62
+ return 0
63
+
64
+
65
+ def cmd_pair(args, dev: Device) -> int:
66
+ was_online = dev.online()
67
+ if not args.force and was_online:
68
+ print(f"Already linked on {dev.path} (pass --force to re-pair).")
69
+ return 0
70
+
71
+ try:
72
+ state, _secs = dev.pair_status()
73
+ except DeviceError as exc:
74
+ if not exc.retryable:
75
+ raise DeviceError(
76
+ "pairing is only supported on the 2.4 GHz receiver (25a7:fa7c), "
77
+ "not over the USB-C cable"
78
+ ) from exc
79
+ raise
80
+
81
+ if args.force or state != PAIR_LISTENING:
82
+ dev.enter_pair()
83
+
84
+ print(
85
+ f"m693ctl: receiver {dev.path} is listening for 2.4 GHz pairing:\n"
86
+ " 1. Unplug the USB-C cable, flip the bottom switch to 2.4G, and hold\n"
87
+ " the mouse within ~10 cm of the receiver.\n"
88
+ " 2. Hold Left + Middle + Right for 3-4 s until the LED flashes rapidly.\n"
89
+ " (If your mouse is already paired and just asleep, click/move it first;\n"
90
+ " Ctrl-C cancels without unpairing.)",
91
+ file=sys.stderr,
92
+ )
93
+
94
+ deadline = (
95
+ None if args.timeout is None else time.monotonic() + args.timeout
96
+ )
97
+ while True:
98
+ online = dev.online()
99
+ if not online:
100
+ was_online = False
101
+ state, _secs = dev.pair_status()
102
+ if online and (not was_online or state != PAIR_LISTENING):
103
+ print(
104
+ f"Paired on {dev.path}. Leave the mouse untouched for ~5-15 s "
105
+ "so the RF link settles."
106
+ )
107
+ return 0
108
+ if deadline is not None and time.monotonic() >= deadline:
109
+ print(
110
+ "error: pairing timed out without an RF handshake.",
111
+ file=sys.stderr,
112
+ )
113
+ return 1
114
+ if state != PAIR_LISTENING:
115
+ dev.enter_pair()
116
+ time.sleep(0.4)
117
+
118
+
119
+ def _battery(dev: Device) -> tuple[int, bool]:
120
+ """Charge, refusing a reading the receiver cannot vouch for.
121
+
122
+ A receiver with no mouse linked still answers GetPower, with the last
123
+ figure it saw - indistinguishable from a live one, and wrong. A charge is
124
+ only meaningful when the mouse itself is on the other end.
125
+ """
126
+ if not dev.online():
127
+ raise DeviceError(
128
+ "the mouse is not connected, so its charge would be a stale "
129
+ "reading from the receiver",
130
+ retryable=True,
131
+ )
132
+ return dev.power()
133
+
134
+
135
+ def _battery_text(dev: Device) -> str:
136
+ """Battery as one line, or why it could not be read."""
137
+ try:
138
+ percent, charging = _battery(dev)
139
+ except DeviceError as exc:
140
+ return f"unavailable ({exc})"
141
+ return f"{percent}%" + (" (charging)" if charging else "")
142
+
143
+
144
+ def cmd_battery(args, dev: Device) -> int:
145
+ percent, charging = _battery(dev)
146
+ if args.quiet:
147
+ print(percent)
148
+ else:
149
+ print(f"{percent}%" + (" (charging)" if charging else ""))
150
+ return 0
151
+
152
+
153
+ def cmd_info(args, dev: Device) -> int:
154
+ prof = P.Profile.read_from(dev)
155
+ print(f"device {dev.path}")
156
+ print(f"link {'online' if dev.online() else 'offline'}")
157
+ print(f"current profile {dev.current_profile()}")
158
+ print(f"battery {_battery_text(dev)}")
159
+ print(f"polling rate {prof.polling_rate} Hz")
160
+ print(f"lift-off distance {prof.lod}")
161
+ print(f"debounce {prof.debounce_ms} ms")
162
+ print(f"dormancy {prof.dormancy_s} s")
163
+ print(f"angle snapping {'on' if prof.angle_snapping else 'off'}")
164
+ print(f"motion sync {'on' if prof.motion_sync else 'off'}")
165
+ print(f"ripple control {'on' if prof.ripple_control else 'off'}")
166
+ print(f"light off moving {'on' if prof.light_off_when_moving else 'off'}")
167
+ print()
168
+ print(f"DPI stages {prof.dpi_count} (active: stage {prof.dpi_active + 1})")
169
+ for i, ((x, y), (r, g, b)) in enumerate(zip(prof.dpi_stages, prof.dpi_colors)):
170
+ marker = "*" if i == prof.dpi_active else " "
171
+ value = f"{x}" if x == y else f"{x}x{y}"
172
+ print(f" {marker} stage {i + 1}: {value:>9} DPI #{r:02x}{g:02x}{b:02x}")
173
+ print()
174
+ for zone in L.distinct(L.ZONES):
175
+ state = L.read(prof, zone)
176
+ r, g, b = state.color
177
+ print(f"{L.group_label(zone)}")
178
+ print(f" effect {state.mode_name}")
179
+ print(f" colour #{r:02x}{g:02x}{b:02x}")
180
+ print(f" brightness {state.brightness}")
181
+ print(f" speed {L.speed_from_raw(state.speed)} (1 slow .. 10 fast)")
182
+ print(" (the wheel's colour is the active DPI stage's)")
183
+ print()
184
+ print("buttons")
185
+ combo = _read_combo(dev)
186
+ for i, entry in enumerate(prof.keymap):
187
+ if entry == (0, 0, 0) and i >= 11:
188
+ continue
189
+ record = combo[K.COMBO_STRIDE * i : K.COMBO_STRIDE * (i + 1)]
190
+ print(f" slot {i:2d}: {K.describe(entry, record)}")
191
+ bad = prof.bad_groups()
192
+ if bad:
193
+ print()
194
+ print("warning: checksum mismatch at " + ", ".join(f"{a:#04x}" for a in bad))
195
+ return 0
196
+
197
+
198
+ def cmd_dump(args, dev: Device) -> int:
199
+ length = args.length
200
+ data = dev.read(args.addr, length)
201
+ if args.output:
202
+ with open(args.output, "wb") as fh:
203
+ fh.write(data)
204
+ print(f"wrote {len(data)} bytes to {args.output}")
205
+ return 0
206
+ for off in range(0, len(data), 16):
207
+ row = data[off : off + 16]
208
+ text = "".join(chr(b) if 32 <= b < 127 else "." for b in row)
209
+ print(f"{args.addr + off:04x} {' '.join(f'{b:02x}' for b in row):<47} {text}")
210
+ return 0
211
+
212
+
213
+ def cmd_restore(args, dev: Device) -> int:
214
+ with open(args.input, "rb") as fh:
215
+ data = fh.read()
216
+ dev.write(args.addr, data)
217
+ print(f"restored {len(data)} bytes at {args.addr:#04x}")
218
+ return 0
219
+
220
+
221
+ def cmd_rate(args, dev: Device) -> int:
222
+ P.set_polling_rate(dev, args.hz)
223
+ print(f"polling rate set to {args.hz} Hz")
224
+ return 0
225
+
226
+
227
+ def cmd_dpi(args, dev: Device) -> int:
228
+ if args.stages:
229
+ stages = []
230
+ for item in args.stages:
231
+ if "x" in item:
232
+ x, y = item.split("x", 1)
233
+ stages.append((int(x), int(y)))
234
+ else:
235
+ stages.append(int(item))
236
+ P.set_dpi_stages(dev, stages)
237
+ print(f"set {len(stages)} DPI stages")
238
+ if args.active is not None:
239
+ P.set_dpi_active(dev, args.active - 1)
240
+ print(f"active DPI stage set to {args.active}")
241
+ if args.color:
242
+ index, color = args.color
243
+ P.set_dpi_color(dev, int(index) - 1, _color(color))
244
+ print(f"stage {index} colour set to #{color.lstrip('#')}")
245
+ if not (args.stages or args.active is not None or args.color):
246
+ prof = P.Profile.read_from(dev)
247
+ for i, (x, y) in enumerate(prof.dpi_stages):
248
+ marker = "*" if i == prof.dpi_active else " "
249
+ print(f"{marker} stage {i + 1}: {x if x == y else f'{x}x{y}'} DPI")
250
+ return 0
251
+
252
+
253
+ def _zones(name: str) -> list[str]:
254
+ """Zones a --zone value names, one entry per independent channel."""
255
+ return L.distinct(L.ZONES if name == "all" else (name,))
256
+
257
+
258
+ def cmd_led(args, dev: Device) -> int:
259
+ targets = _zones(args.zone)
260
+ if args.mode:
261
+ mode = P.LED_MODE_NAMES.get(args.mode)
262
+ if mode is None:
263
+ raise SystemExit(f"unknown LED mode; pick one of {sorted(P.LED_MODE_NAMES)}")
264
+ else:
265
+ mode = None
266
+ if not any((mode is not None, args.brightness is not None,
267
+ args.speed is not None, args.color)):
268
+ prof = P.Profile.read_from(dev)
269
+ for zone in targets:
270
+ state = L.read(prof, zone)
271
+ r, g, b = state.color
272
+ print(f"{L.group_label(zone)}")
273
+ print(f" mode {state.mode_name}")
274
+ print(f" brightness {state.brightness}")
275
+ print(f" speed {L.speed_from_raw(state.speed)}")
276
+ print(f" colour #{r:02x}{g:02x}{b:02x}")
277
+ return 0
278
+
279
+ # Build each zone's new state from what it currently holds, so a partial
280
+ # command leaves the fields it did not name alone.
281
+ prof = P.Profile.read_from(dev)
282
+ for zone in targets:
283
+ state = L.read(prof, zone)
284
+ if mode is not None:
285
+ state.mode = mode
286
+ if args.brightness is not None:
287
+ state.brightness = args.brightness
288
+ if args.speed is not None:
289
+ # The device stores a delay; --speed counts the way people do.
290
+ state.speed = L.speed_to_raw(args.speed)
291
+ if args.color:
292
+ state.color = _color(args.color)
293
+ L.apply(dev, zone, state, color=bool(args.color))
294
+ if args.color and L.ZONE_WHEEL in targets:
295
+ print(
296
+ "note: the wheel's colour is the DPI stage colour table, so every "
297
+ "stage is now that colour",
298
+ file=sys.stderr,
299
+ )
300
+ return 0
301
+
302
+
303
+ def cmd_preset(args, dev: Device) -> int:
304
+ if args.action != "list" and not args.name:
305
+ raise SystemExit(f"preset {args.action} needs a name")
306
+ if args.action == "list":
307
+ for preset in PRESETS.load_all():
308
+ tag = " (built-in)" if PRESETS.is_builtin(preset.name) else ""
309
+ zones = ", ".join(
310
+ f"{L.ZONE_LABELS[z]}: {preset.zones[z].mode_name}"
311
+ for z in L.distinct(preset.zones)
312
+ )
313
+ print(f"{preset.name}{tag} - {zones}")
314
+ return 0
315
+ if args.action == "save":
316
+ if PRESETS.is_builtin(args.name):
317
+ raise SystemExit(f"{args.name!r} is a built-in preset name")
318
+ PRESETS.store(L.Preset.capture(args.name, P.Profile.read_from(dev)))
319
+ print(f"saved {args.name!r} to {PRESETS.PRESET_PATH}")
320
+ return 0
321
+ if args.action == "delete":
322
+ if not PRESETS.remove(args.name):
323
+ raise SystemExit(f"no saved preset called {args.name!r}")
324
+ print(f"deleted {args.name!r}")
325
+ return 0
326
+ preset = PRESETS.find(args.name)
327
+ if preset is None:
328
+ raise SystemExit(f"no preset called {args.name!r} (try: m693ctl preset list)")
329
+ preset.apply(dev, color=not args.keep_dpi_colors)
330
+ print(f"applied {preset.name!r}")
331
+ return 0
332
+
333
+
334
+ def cmd_set(args, dev: Device) -> int:
335
+ if args.lod is not None:
336
+ P.set_lod(dev, args.lod)
337
+ if args.debounce is not None:
338
+ P.set_debounce(dev, args.debounce)
339
+ if args.dormancy is not None:
340
+ P.set_dormancy(dev, args.dormancy)
341
+ if args.angle_snap is not None:
342
+ P.set_flag(dev, P.ADDR_ANGLE_SNAP, args.angle_snap)
343
+ if args.motion_sync is not None:
344
+ P.set_flag(dev, P.ADDR_MOTION_SYNC, args.motion_sync)
345
+ if args.ripple is not None:
346
+ P.set_flag(dev, P.ADDR_RIPPLE, args.ripple)
347
+ if args.light_off_moving is not None:
348
+ P.set_flag(dev, P.ADDR_LIGHT_OFF_MOVE, args.light_off_moving)
349
+ print("applied")
350
+ return 0
351
+
352
+
353
+ def _read_combo(dev: Device) -> bytes:
354
+ try:
355
+ return dev.read(K.ADDR_COMBO, K.COMBO_REGION_LEN)
356
+ except DeviceError:
357
+ return bytes(K.COMBO_REGION_LEN)
358
+
359
+
360
+ def _parse_action(action: str) -> K.Binding:
361
+ """Turn a CLI action word into a binding.
362
+
363
+ Accepts a catalogue id ("play-pause"), a parameterised form
364
+ ("fire:10,3", "dpi-lock:2", "macro:0"), a key combination
365
+ ("key:ctrl+shift+t"), or "raw:type,p1,p2" as an escape hatch.
366
+ """
367
+ if action.startswith("raw:"):
368
+ parts = [int(x, 0) for x in action[4:].split(",")]
369
+ return K.Binding(*(parts + [0, 0])[:3])
370
+ if action.startswith("key:"):
371
+ parts = action[4:].lower().split("+")
372
+ modifiers = sum(K.MODIFIER_VALUES[p] for p in parts if p in K.MODIFIER_VALUES)
373
+ names = [p for p in parts if p not in K.MODIFIER_VALUES]
374
+ for name in names:
375
+ if name not in K.KEYBOARD_USAGES:
376
+ raise SystemExit(
377
+ f"unknown key {name!r}; try one of: "
378
+ + ", ".join(sorted(K.KEYBOARD_USAGES))
379
+ )
380
+ return K.build("key-combo", modifiers=modifiers, keys=names)
381
+ name, _, argument = action.partition(":")
382
+ if name not in K.FUNCTIONS_BY_ID:
383
+ raise SystemExit(
384
+ "unknown action; try one of:\n "
385
+ + "\n ".join(
386
+ f"{fn.id:<16} {fn.label}" for fn in K.FUNCTIONS
387
+ )
388
+ + "\n key:ctrl+shift+t\n raw:type,p1,p2"
389
+ )
390
+ fn = K.FUNCTIONS_BY_ID[name]
391
+ if not fn.param:
392
+ return K.build(name)
393
+ values = [int(x, 0) for x in argument.split(",")] if argument else []
394
+ if fn.param == "fire":
395
+ interval, shots = (values + [10, 3])[:2]
396
+ return K.build(name, interval=interval, shots=shots)
397
+ if fn.param == "dpi-stage":
398
+ return K.build(name, stage=(values + [0])[0])
399
+ if fn.param == "macro":
400
+ return K.build(name, slot=(values + [0])[0])
401
+ raise SystemExit(f"{name} needs an argument, e.g. {name}:1")
402
+
403
+
404
+ def cmd_button(args, dev: Device) -> int:
405
+ binding = _parse_action(args.action)
406
+ P.set_binding(dev, args.slot, binding)
407
+ record = binding.combo or b""
408
+ print(f"slot {args.slot} set to {K.describe(binding.entry, record)}")
409
+ return 0
410
+
411
+
412
+ def cmd_macro(args, dev: Device) -> int:
413
+ slot = args.slot
414
+ if args.clear:
415
+ MACRO.clear_macro(dev, slot)
416
+ print(f"slot {slot} macro cleared")
417
+ return 0
418
+ if args.load:
419
+ with open(args.load, "rb") as fh:
420
+ data = fh.read()
421
+ if args.load.lower().endswith(".jmm"):
422
+ macro = MACRO.from_jmm(data)
423
+ else:
424
+ macro = MACRO.Macro.from_json(json.loads(data.decode()))
425
+ MACRO.write_macro(dev, slot, macro)
426
+ if args.repeat:
427
+ P.set_key(dev, slot, K.KEY_TYPE_MACRO, slot, args.repeat)
428
+ print(
429
+ f"slot {slot}: {macro.name!r}, {len(macro.events)} steps"
430
+ + (f", bound to play {MACRO.repeat_label(args.repeat)}" if args.repeat else "")
431
+ )
432
+ return 0
433
+ macro = MACRO.read_macro(dev, slot)
434
+ if macro.is_empty:
435
+ print(f"slot {slot}: empty")
436
+ return 0
437
+ print(f"slot {slot}: {macro.name!r}, {len(macro.events)} steps, "
438
+ f"{macro.duration_ms()} ms")
439
+ for index, event in enumerate(macro.events):
440
+ print(f" {index + 1:2d}. {event.label():<24} +{event.delay_ms} ms")
441
+ return 0
442
+
443
+
444
+ def cmd_export(args, dev: Device) -> int:
445
+ document = transfer.read_device(dev)
446
+ with open(args.path, "w") as fh:
447
+ json.dump(document, fh, indent=2)
448
+ fh.write("\n")
449
+ print(f"wrote {args.path}")
450
+ return 0
451
+
452
+
453
+ def cmd_import(args, dev: Device) -> int:
454
+ with open(args.path) as fh:
455
+ document = json.load(fh)
456
+ image, combo = transfer.from_json(document)
457
+ transfer.write_device(dev, image, combo)
458
+ print(f"restored {args.path}")
459
+ return 0
460
+
461
+
462
+ def cmd_raw(args, dev: Device) -> int:
463
+ data = bytes(int(x, 0) for x in args.bytes)
464
+ dev.write(args.addr, data)
465
+ print(f"wrote {len(data)} bytes at {args.addr:#04x}")
466
+ return 0
467
+
468
+
469
+ # ---------------------------------------------------------------- entrypoint
470
+ def build_parser() -> argparse.ArgumentParser:
471
+ ap = argparse.ArgumentParser(
472
+ prog="m693ctl", description="Configure a Redragon M693 mouse on Linux"
473
+ )
474
+ ap.add_argument("--device", help="hidraw node to use (default: autodetect)")
475
+ ap.add_argument(
476
+ "--wait",
477
+ type=_wait,
478
+ default=None,
479
+ metavar="SECONDS",
480
+ help="how long to keep retrying while the mouse is asleep: a number of "
481
+ "seconds, 'forever' (the default), or 0 to fail immediately",
482
+ )
483
+ ap.add_argument(
484
+ "--version", action="version", version=f"m693ctl {__version__}"
485
+ )
486
+ sub = ap.add_subparsers(dest="cmd", required=True)
487
+
488
+ sub.add_parser("list", help="list detected M693 control interfaces")
489
+ sub.add_parser("info", help="show the full current configuration")
490
+
491
+ p = sub.add_parser("pair", help="pair the mouse with its 2.4 GHz USB receiver")
492
+ p.add_argument(
493
+ "--timeout",
494
+ type=_wait,
495
+ default=60.0,
496
+ metavar="SECONDS",
497
+ help="how long to listen for pairing (default: 60, or 'forever')",
498
+ )
499
+ p.add_argument(
500
+ "--force",
501
+ action="store_true",
502
+ help="enter pairing mode even if a mouse already appears linked",
503
+ )
504
+
505
+ p = sub.add_parser("battery", help="show the battery charge")
506
+ p.add_argument(
507
+ "-q", "--quiet", action="store_true",
508
+ help="print just the number, for status bars and scripts",
509
+ )
510
+
511
+ p = sub.add_parser("dump", help="hex-dump on-board configuration memory")
512
+ p.add_argument("--addr", type=lambda x: int(x, 0), default=0x00)
513
+ p.add_argument("--length", type=lambda x: int(x, 0), default=P.PROFILE_LEN)
514
+ p.add_argument("-o", "--output", help="write raw bytes to a file instead")
515
+
516
+ p = sub.add_parser("restore", help="write a previously dumped image back")
517
+ p.add_argument("input")
518
+ p.add_argument("--addr", type=lambda x: int(x, 0), default=0x00)
519
+
520
+ p = sub.add_parser("rate", help="set the USB polling rate")
521
+ p.add_argument("hz", type=int, choices=sorted(P.POLL_DIVISORS))
522
+
523
+ p = sub.add_parser("dpi", help="inspect or change DPI stages")
524
+ p.add_argument("stages", nargs="*", help="e.g. 800 1600 3200, or 800x400")
525
+ p.add_argument("--active", type=int, help="1-based stage to activate")
526
+ p.add_argument("--color", nargs=2, metavar=("STAGE", "RRGGBB"))
527
+
528
+ p = sub.add_parser("led", help="inspect or change lighting")
529
+ p.add_argument(
530
+ "--zone",
531
+ choices=(*L.ZONES, "all"),
532
+ default=L.ZONE_STRIPS,
533
+ help=f"{L.ZONE_STRIPS} (default) and {L.ZONE_LOGO} are one channel on "
534
+ f"this model - either name moves both; {L.ZONE_WHEEL} = scroll wheel; "
535
+ "all = every zone",
536
+ )
537
+ p.add_argument("--mode", choices=sorted(P.LED_MODE_NAMES))
538
+ p.add_argument("--brightness", type=int)
539
+ p.add_argument(
540
+ "--speed", type=int,
541
+ help=f"{L.SPEED_MIN} (slowest) to {L.SPEED_MAX} (fastest)",
542
+ )
543
+ p.add_argument("--color", help="RRGGBB")
544
+
545
+ p = sub.add_parser("preset", help="saved lighting looks")
546
+ p.add_argument("action", choices=("list", "apply", "save", "delete"))
547
+ p.add_argument("name", nargs="?", default="")
548
+ p.add_argument(
549
+ "--keep-dpi-colors",
550
+ action="store_true",
551
+ help="apply the preset's effects but leave the DPI stage colours "
552
+ "(and so the wheel's colour) alone",
553
+ )
554
+
555
+ p = sub.add_parser("set", help="change sensor and power settings")
556
+ p.add_argument("--lod", type=int)
557
+ p.add_argument("--debounce", type=int, metavar="MS")
558
+ p.add_argument("--dormancy", type=int, metavar="SECONDS")
559
+ p.add_argument("--angle-snap", type=_onoff)
560
+ p.add_argument("--motion-sync", type=_onoff)
561
+ p.add_argument("--ripple", type=_onoff, help="ripple control")
562
+ p.add_argument("--light-off-moving", type=_onoff)
563
+
564
+ p = sub.add_parser("button", help="remap a button slot")
565
+ p.add_argument("slot", type=int)
566
+ p.add_argument(
567
+ "action",
568
+ help="function id, fire:MS,SHOTS, dpi-lock:STAGE, "
569
+ "key:ctrl+shift+t, or raw:type,p1,p2",
570
+ )
571
+
572
+ p = sub.add_parser("macro", help="inspect or load a button's macro")
573
+ p.add_argument("slot", type=int)
574
+ p.add_argument("--load", metavar="FILE", help="a .jmm or .json macro to write")
575
+ p.add_argument(
576
+ "--repeat", type=int, metavar="N",
577
+ help="also bind the button: N times, 254 to toggle, 255 while held",
578
+ )
579
+ p.add_argument("--clear", action="store_true", help="empty the slot")
580
+
581
+ p = sub.add_parser("export", help="save the whole configuration as JSON")
582
+ p.add_argument("path")
583
+
584
+ p = sub.add_parser("import", help="restore a configuration saved with export")
585
+ p.add_argument("path")
586
+
587
+ p = sub.add_parser("raw", help="write raw bytes (checksums NOT added)")
588
+ p.add_argument("addr", type=lambda x: int(x, 0))
589
+ p.add_argument("bytes", nargs="+")
590
+
591
+ return ap
592
+
593
+
594
+ HANDLERS = {
595
+ "info": cmd_info,
596
+ "pair": cmd_pair,
597
+ "battery": cmd_battery,
598
+ "dump": cmd_dump,
599
+ "restore": cmd_restore,
600
+ "rate": cmd_rate,
601
+ "dpi": cmd_dpi,
602
+ "led": cmd_led,
603
+ "preset": cmd_preset,
604
+ "set": cmd_set,
605
+ "button": cmd_button,
606
+ "macro": cmd_macro,
607
+ "export": cmd_export,
608
+ "import": cmd_import,
609
+ "raw": cmd_raw,
610
+ }
611
+
612
+
613
+ def _asleep_notice() -> None:
614
+ print(
615
+ "m693ctl: the mouse is asleep - move or click it and this will "
616
+ "continue on its own (Ctrl-C to give up)",
617
+ file=sys.stderr,
618
+ )
619
+
620
+
621
+ def _run(args, dev: Device, deadline: float | None) -> int:
622
+ """Run the subcommand, resuming if the mouse dozes off part-way through.
623
+
624
+ Every subcommand is idempotent - each one sets a field to a stated value -
625
+ so re-running one after a half-finished write is safe and converges on the
626
+ right result. It is only worth retrying when the device went quiet;
627
+ an explicit refusal is reported straight away.
628
+ """
629
+ while True:
630
+ try:
631
+ return HANDLERS[args.cmd](args, dev)
632
+ except DeviceError as exc:
633
+ if not exc.retryable:
634
+ raise
635
+ if deadline is not None and time.monotonic() >= deadline:
636
+ raise
637
+ remaining = None if deadline is None else max(
638
+ 0.0, deadline - time.monotonic()
639
+ )
640
+ if not dev.wait_online(remaining, notify=_asleep_notice):
641
+ raise
642
+ print("m693ctl: mouse is back, retrying", file=sys.stderr)
643
+
644
+
645
+ def main(argv: list[str] | None = None) -> int:
646
+ args = build_parser().parse_args(argv)
647
+ if args.cmd == "list":
648
+ return cmd_list(args)
649
+ # Managing the preset file touches no hardware, so do not make the user
650
+ # wake the mouse to list or delete one.
651
+ if args.cmd == "preset" and args.action in ("list", "delete"):
652
+ return cmd_preset(args, None)
653
+ wait = args.wait if hasattr(args, "wait") else None
654
+ deadline = None if wait is None else time.monotonic() + wait
655
+ try:
656
+ if args.cmd == "pair":
657
+ path = args.device
658
+ if path is None:
659
+ receivers = find_receivers()
660
+ if not receivers:
661
+ raise DeviceError(
662
+ "No 2.4 GHz receiver (25a7:fa7c) found. Plug the USB "
663
+ "dongle in first."
664
+ )
665
+ path = receivers[0]
666
+ with Device(path, retries=3) as dev:
667
+ return cmd_pair(args, dev)
668
+ with Device(args.device) as dev:
669
+ try:
670
+ dev.set_driver_status(True)
671
+ if not dev.wait_online(wait, notify=_asleep_notice):
672
+ print(
673
+ "error: the mouse is not responding - it is asleep, powered "
674
+ "off, or out of range. Move or click it, then retry.",
675
+ file=sys.stderr,
676
+ )
677
+ return 1
678
+ return _run(args, dev, deadline)
679
+ finally:
680
+ dev.release()
681
+ except DeviceError as exc:
682
+ print(f"error: {exc}", file=sys.stderr)
683
+ return 1
684
+ except KeyboardInterrupt:
685
+ print("\nm693ctl: gave up waiting", file=sys.stderr)
686
+ return 130
687
+
688
+
689
+ if __name__ == "__main__":
690
+ sys.exit(main())