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 +6 -0
- m693/cli.py +690 -0
- m693/device.py +431 -0
- m693/keys.py +415 -0
- m693/lighting.py +321 -0
- m693/macro.py +371 -0
- m693/presets.py +93 -0
- m693/profile.py +539 -0
- m693/stock-profile.bin +0 -0
- m693/transfer.py +103 -0
- m693ctl-1.3.0.dist-info/METADATA +371 -0
- m693ctl-1.3.0.dist-info/RECORD +38 -0
- m693ctl-1.3.0.dist-info/WHEEL +5 -0
- m693ctl-1.3.0.dist-info/entry_points.txt +5 -0
- m693ctl-1.3.0.dist-info/licenses/LICENSE +28 -0
- m693ctl-1.3.0.dist-info/top_level.txt +2 -0
- m693gui/__init__.py +4 -0
- m693gui/app.py +310 -0
- m693gui/artwork.py +45 -0
- m693gui/assets/arrow-down.svg +3 -0
- m693gui/assets/m693.svg +8 -0
- m693gui/assets/theme.qss +237 -0
- m693gui/backend.py +101 -0
- m693gui/library.py +91 -0
- m693gui/model.py +406 -0
- m693gui/tabs/__init__.py +0 -0
- m693gui/tabs/dpi.py +254 -0
- m693gui/tabs/keys.py +388 -0
- m693gui/tabs/lighting.py +456 -0
- m693gui/tabs/macro.py +467 -0
- m693gui/tabs/placeholder.py +72 -0
- m693gui/widgets/__init__.py +0 -0
- m693gui/widgets/chrome.py +185 -0
- m693gui/widgets/controls.py +157 -0
- m693gui/widgets/lighting.py +323 -0
- m693gui/widgets/mouseview.py +136 -0
- m693gui/widgets/recorder.py +134 -0
- m693gui/worker.py +232 -0
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())
|