Add the ITPROTECH MK series (8808) as a third pad family
Confirmed on the MK-09 (8808:660a, 9 keys): the vendor's .NET configurator was decompiled for the wire format and every frame was then written to a real pad and read back. Keys, media keys and multikey sequences are supported; lighting from the command line. The firmware acts on every other output report, so each report is sent twice (as the vendor software does) and every write is verified by reading the key back. The app now keeps one state file per pad and starts with a known layout for a known USB id, and the editor asks the backend whether a binding fits before offering to write it. The actions listener and the udev rule cover every supported vendor. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -501,8 +501,13 @@ def _descriptor_keyboard_report_id(raw):
|
||||
return False, 0
|
||||
|
||||
|
||||
def find_keyboard_nodes(vid=core.VID):
|
||||
"""[(hidraw path, keyboard report id)] for every pad keyboard interface."""
|
||||
def find_keyboard_nodes(vid=None):
|
||||
"""
|
||||
[(hidraw path, keyboard report id)] for every pad keyboard interface.
|
||||
|
||||
vid narrows it to one vendor; the default is every family we support.
|
||||
"""
|
||||
want = {vid.lower()} if vid else core.vendors()
|
||||
out = []
|
||||
for node in sorted(glob.glob("/sys/class/hidraw/hidraw*")):
|
||||
dev = os.path.join(node, "device")
|
||||
@@ -513,7 +518,7 @@ def find_keyboard_nodes(vid=core.VID):
|
||||
continue
|
||||
hid_id = next((l.split("=", 1)[1] for l in uevent.splitlines()
|
||||
if l.startswith("HID_ID=")), "")
|
||||
if hid_id.count(":") != 2 or hid_id.split(":")[1][-4:].lower() != vid:
|
||||
if hid_id.count(":") != 2 or hid_id.split(":")[1][-4:].lower() not in want:
|
||||
continue
|
||||
is_kbd, rid = _descriptor_keyboard_report_id(raw)
|
||||
if is_kbd:
|
||||
|
||||
@@ -243,6 +243,7 @@ class Inspector(QWidget):
|
||||
self.control = None
|
||||
self._loading = False
|
||||
self.editing_layout = False
|
||||
self.backend = None # the family of the pad plugged in, if any
|
||||
|
||||
v = QVBoxLayout(self)
|
||||
v.setContentsMargins(24, 24, 24, 16)
|
||||
@@ -596,13 +597,20 @@ class Inspector(QWidget):
|
||||
self.keys_hint.setText(f"<span style='color:{BAD}'>{e}</span>")
|
||||
self.changed.emit(self.control, None)
|
||||
return
|
||||
if b.is_sequence and not core.SEQUENCES_VERIFIED:
|
||||
sequences_ok = (self.backend.sequences if self.backend is not None
|
||||
else core.SEQUENCES_VERIFIED)
|
||||
if b.is_sequence and not sequences_ok:
|
||||
self.keys_hint.setText(
|
||||
f"<span style='color:{BAD}'>Sequences aren't verified on this "
|
||||
"pad yet, so they're switched off for now. One shortcut per "
|
||||
"control.</span>")
|
||||
self.changed.emit(self.control, None)
|
||||
return
|
||||
why = self.backend.check(b) if self.backend is not None else None
|
||||
if why:
|
||||
self.keys_hint.setText(f"<span style='color:{BAD}'>{why}</span>")
|
||||
self.changed.emit(self.control, None)
|
||||
return
|
||||
self.keys_hint.setText(f"Sends {b.label()}")
|
||||
self.changed.emit(self.control, b)
|
||||
|
||||
@@ -1170,6 +1178,7 @@ class Window(QMainWindow):
|
||||
f"{d.device.vid}:{d.device.pid}" in self.adopted:
|
||||
self.diag = core.Diagnosis("ready", f"Ready on {d.device.path}.",
|
||||
device=d.device, warnings=d.warnings)
|
||||
switched = self._follow_device()
|
||||
colour = {"ready": GOOD, "unplugged": INK_DIM.name()}.get(self.diag.status, BAD)
|
||||
self.dot.setStyleSheet(f"background: {colour}; border-radius: 4px;")
|
||||
if self.diag.device:
|
||||
@@ -1186,11 +1195,40 @@ class Window(QMainWindow):
|
||||
self.inspector.show_problem(self.diag, self.note or self.shape_note,
|
||||
offer_detect=bool(self.shape_note))
|
||||
self.inspector.show_background(bool(act.listener_pid()))
|
||||
if self.diag.status != old:
|
||||
self._refresh(editor=False)
|
||||
if self.diag.ready and old in ("unplugged", "no-interface", None):
|
||||
if self.diag.status != old or switched:
|
||||
self._refresh(editor=switched)
|
||||
if self.diag.ready and (switched or
|
||||
old in ("unplugged", "no-interface", None)):
|
||||
self._read(quiet=True)
|
||||
|
||||
def _follow_device(self):
|
||||
"""
|
||||
Keep the shadow state and the drawing on the pad that is plugged in.
|
||||
|
||||
Each family keeps its own state file. The first time a pad with a
|
||||
known USB id turns up it is drawn with the layout we know for it,
|
||||
so a 9 key pad is never shown as 12 keys and 2 knobs. Returns True
|
||||
when the state was switched to another pad.
|
||||
"""
|
||||
dev = self.diag.device if self.diag else None
|
||||
if not dev:
|
||||
self.inspector.backend = None
|
||||
return False
|
||||
self.inspector.backend = core.backend_for(dev)
|
||||
ident = f"{dev.vid}:{dev.pid}"
|
||||
if ident == self.state.device_id:
|
||||
return False
|
||||
state = core.State(device_id=ident)
|
||||
if not state.path.exists():
|
||||
known = core.KNOWN_LAYOUTS.get((dev.vid, dev.pid))
|
||||
if known:
|
||||
state.layout = known
|
||||
self.state = state
|
||||
self.desired.clear()
|
||||
self.pending_actions.clear()
|
||||
self.pad.set_layout(self.state.layout) # drops a stale selection
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------- write
|
||||
|
||||
def _write(self, controls):
|
||||
@@ -1339,7 +1377,7 @@ class Window(QMainWindow):
|
||||
return
|
||||
|
||||
layout = found.layout
|
||||
self.state = core.State(layout=layout)
|
||||
self.state = core.State(layout=layout, device_id=f"{dev.vid}:{dev.pid}")
|
||||
self.state.layout = layout
|
||||
for control, binding in found.bindings.items():
|
||||
self.state.record(control, binding)
|
||||
|
||||
@@ -8,9 +8,10 @@ base.py and listing the class here.
|
||||
"""
|
||||
|
||||
from .ch57x import CH57x
|
||||
from .itprotech import Itprotech
|
||||
from .sayo import Sayo
|
||||
|
||||
REGISTRY = (CH57x(), Sayo()) # tried in order; first to claim a device wins
|
||||
REGISTRY = (CH57x(), Sayo(), Itprotech()) # tried in order; first to claim a device wins
|
||||
DEFAULT = REGISTRY[0]
|
||||
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ class Backend:
|
||||
layers = 1
|
||||
max_keys = 0
|
||||
max_knobs = 0
|
||||
sequences = False # can one control send several keys in a row?
|
||||
|
||||
def claims(self, vid, pid):
|
||||
"""True only for ids this backend has been confirmed on."""
|
||||
@@ -40,6 +41,13 @@ class Backend:
|
||||
def supports(self, keys, knobs):
|
||||
return 1 <= keys <= self.max_keys and 0 <= knobs <= self.max_knobs
|
||||
|
||||
def check(self, binding):
|
||||
"""
|
||||
Why this family can't hold a binding the GUI thinks is valid, or
|
||||
None. Lets the editor say so while typing instead of at write time.
|
||||
"""
|
||||
return None
|
||||
|
||||
# The six a family must answer.
|
||||
|
||||
def action_byte(self, control):
|
||||
|
||||
@@ -35,6 +35,11 @@ class CH57x(Backend):
|
||||
max_keys = proto.KEY_SLOTS
|
||||
max_knobs = proto.KNOB_SLOTS
|
||||
|
||||
@property
|
||||
def sequences(self):
|
||||
from .. import core
|
||||
return core.SEQUENCES_VERIFIED
|
||||
|
||||
# ------------------------------------------------------------ protocol
|
||||
|
||||
def action_byte(self, control):
|
||||
|
||||
254
macropad_gui/backends/itprotech.py
Normal file
254
macropad_gui/backends/itprotech.py
Normal file
@@ -0,0 +1,254 @@
|
||||
"""
|
||||
ITPROTECH MK series backend (USB vendor id 8808). Confirmed on the MK-09,
|
||||
the 9 key pad, by reading and writing it; the wire format and the reasons
|
||||
behind it are in itprotech_proto.py.
|
||||
|
||||
Three things about this firmware shape the code:
|
||||
|
||||
* One read command returns everything: every key's record, the lighting
|
||||
mode and the custom colour. There are no layers and no knobs, and a
|
||||
key is addressed by its number, 1 upwards, left to right, top to bottom.
|
||||
* It acts on every other output report. Each report goes out twice, and
|
||||
the pad does it exactly once (see REPEAT in the proto module).
|
||||
* It can be read back, so a write is checked: after sending, the key is
|
||||
read again and has to come back byte for byte as sent. A write that
|
||||
doesn't is reported as such, and the control is marked unknown.
|
||||
|
||||
Run this module for the two things the GUI has no place for yet, lighting
|
||||
and colour:
|
||||
|
||||
python3 -m macropad_gui.backends.itprotech show
|
||||
python3 -m macropad_gui.backends.itprotech rgb "press: blue"
|
||||
python3 -m macropad_gui.backends.itprotech colour ff8800
|
||||
"""
|
||||
|
||||
import errno
|
||||
import time
|
||||
|
||||
from . import itprotech_proto as wire
|
||||
from .base import Backend
|
||||
|
||||
|
||||
class Itprotech(Backend):
|
||||
key = "itprotech"
|
||||
name = "ITPROTECH MK series"
|
||||
vid = wire.VID
|
||||
pids = wire.CONFIRMED # 660a; siblings are offered detection first
|
||||
layers = 1
|
||||
max_keys = 12
|
||||
max_knobs = 0
|
||||
sequences = True # multikey: several keys, one modifier mask
|
||||
|
||||
def __init__(self):
|
||||
self._data_len = {} # device path -> key record length seen
|
||||
|
||||
# ------------------------------------------------------------ protocol
|
||||
|
||||
def action_byte(self, control):
|
||||
"""Keys are their own number, from 1. There are no knobs."""
|
||||
if control.startswith("key"):
|
||||
return int(control[3:])
|
||||
raise ValueError(f"{self.name} pads have no knobs: {control}")
|
||||
|
||||
def check(self, binding):
|
||||
try:
|
||||
wire.encode(binding, self._data_len_guess())
|
||||
except ValueError as e:
|
||||
return str(e)
|
||||
return None
|
||||
|
||||
def _data_len_guess(self, device=None):
|
||||
if device is not None and device.path in self._data_len:
|
||||
return self._data_len[device.path]
|
||||
if device is not None and device.pid in wire.DATA_LEN:
|
||||
return wire.DATA_LEN[device.pid]
|
||||
return wire.DATA_LEN[wire.PID_MK09]
|
||||
|
||||
# ---------------------------------------------------------------- read
|
||||
|
||||
def _dump(self, device):
|
||||
"""Everything the pad holds, parsed. Raises core.ReadError."""
|
||||
from .. import core
|
||||
replies = core._exchange(device.path, wire.read_request(),
|
||||
repeat=wire.REPEAT)
|
||||
dump = wire.parse_dump(replies)
|
||||
if not dump["keys"]:
|
||||
# One more go: a stray report from earlier can upset the pad's
|
||||
# every-other-one rhythm, and a second pair puts it right.
|
||||
replies = core._exchange(device.path, wire.read_request(),
|
||||
repeat=wire.REPEAT)
|
||||
dump = wire.parse_dump(replies)
|
||||
if dump["data_len"]:
|
||||
self._data_len[device.path] = dump["data_len"]
|
||||
return dump
|
||||
|
||||
def info(self, device):
|
||||
"""(keys, 0) as the pad reports them, or None if it says nothing."""
|
||||
dump = self._dump(device)
|
||||
if wire.consistent(dump):
|
||||
return None
|
||||
return len(dump["keys"]), 0
|
||||
|
||||
def _bindings(self, dump):
|
||||
from .. import core
|
||||
out = {}
|
||||
for index, report in sorted(dump["keys"].items()):
|
||||
got = wire.decode(report)
|
||||
if got and got[1] is not None:
|
||||
out[f"key{index}"] = got[1]
|
||||
return out
|
||||
|
||||
def read_layer(self, device, layer=1, layout=None):
|
||||
return self._bindings(self._dump(device))
|
||||
|
||||
def lighting(self, device):
|
||||
"""(mode, (r, g, b)) as stored on the pad."""
|
||||
dump = self._dump(device)
|
||||
return dump["rgb"], dump["colour"]
|
||||
|
||||
# --------------------------------------------------------------- write
|
||||
|
||||
def _send(self, device, report, _write=None):
|
||||
"""One report, sent the way this firmware needs it: twice."""
|
||||
import macropad as proto
|
||||
write = _write or proto.write_report
|
||||
for _ in range(wire.REPEAT):
|
||||
write(device.path, report)
|
||||
time.sleep(wire.WRITE_GAP)
|
||||
|
||||
def write(self, device, control, binding, state, layer=1, _write=None):
|
||||
from .. import core
|
||||
index = self.action_byte(control)
|
||||
data_len = self._data_len_guess(device)
|
||||
try:
|
||||
kind, body = wire.encode(binding, data_len)
|
||||
report = wire.key_frame(data_len, index, kind, body)
|
||||
except ValueError as e:
|
||||
raise core.WriteError(f"{control}: {e}", pad_touched=False)
|
||||
|
||||
try:
|
||||
self._send(device, report, _write)
|
||||
except OSError as e:
|
||||
why = core._explain(e)
|
||||
untouched = e.errno in (errno.ENOENT, errno.ENODEV,
|
||||
errno.EACCES, errno.EPERM)
|
||||
if not untouched:
|
||||
state.mark_unknown(control, f"write interrupted: {why}", layer)
|
||||
state.save()
|
||||
raise core.WriteError(f"{control}: {why}",
|
||||
pad_touched=not untouched, cause=e)
|
||||
|
||||
# The pad can be read, so check the write landed before believing it.
|
||||
try:
|
||||
dump = self._dump(device)
|
||||
except core.ReadError as e:
|
||||
state.mark_unknown(control, f"written, but couldn't read back: {e}",
|
||||
layer)
|
||||
state.save()
|
||||
raise core.WriteError(f"{control}: sent, but the pad didn't answer "
|
||||
f"when asked to confirm ({e})",
|
||||
pad_touched=True, cause=e)
|
||||
got = dump["keys"].get(index, b"")
|
||||
want = report[1:data_len + 2] # len byte + record
|
||||
if got[:data_len + 1] != want:
|
||||
state.mark_unknown(control, "the pad holds something other than "
|
||||
"what was written", layer)
|
||||
state.save()
|
||||
raise core.WriteError(f"{control}: the pad didn't take the write "
|
||||
f"(it holds {got[:6].hex(' ') or 'nothing'})",
|
||||
pad_touched=True)
|
||||
state.record(control, binding, layer)
|
||||
state.save()
|
||||
|
||||
def set_lighting(self, device, mode, colour=None, _write=None):
|
||||
"""Lighting mode 0..14, and the custom colour when mode is 14."""
|
||||
self._send(device, wire.rgb_frame(mode), _write)
|
||||
if mode == wire.RGB_CUSTOM and colour is not None:
|
||||
self._send(device, wire.colour_frame(*colour), _write)
|
||||
|
||||
# -------------------------------------------------------------- detect
|
||||
|
||||
def detect(self, device):
|
||||
"""
|
||||
Ask an unknown 8808 pad what it is, reading only.
|
||||
|
||||
The read command is the only query there is, so the test is whether
|
||||
its answer looks like one pad: key records 1..n that agree on their
|
||||
length, plus a lighting reply. A pad that speaks something else sends
|
||||
nothing we recognise and is left alone.
|
||||
"""
|
||||
from .. import core
|
||||
try:
|
||||
dump = self._dump(device)
|
||||
except core.ReadError as e:
|
||||
return core.Detection(why=f"it didn't answer: {e}")
|
||||
why = wire.consistent(dump)
|
||||
if why:
|
||||
return core.Detection(why=why)
|
||||
n = len(dump["keys"])
|
||||
if not self.supports(n, 0):
|
||||
return core.Detection(why=f"it reported {n} keys, more than this "
|
||||
"protocol is known to cover")
|
||||
model = wire.MODELS.get(device.pid, (None, None, f"{n} keys"))[2]
|
||||
return core.Detection(speaks=True, keys=n, knobs=0, layers=1,
|
||||
bindings=self._bindings(dump),
|
||||
model=f"ITPROTECH {model}")
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- cli
|
||||
|
||||
def _main(argv):
|
||||
import sys
|
||||
from .. import core
|
||||
|
||||
def usage():
|
||||
print(__doc__.split("Run this module")[1].strip("\n"))
|
||||
return 2
|
||||
|
||||
if not argv or argv[0] not in ("show", "rgb", "colour", "color"):
|
||||
return usage()
|
||||
d = core.diagnose(keyd=False)
|
||||
if not d.device or d.device.vid != wire.VID:
|
||||
print("No ITPROTECH pad found." if not d.device else d.headline)
|
||||
return 1
|
||||
backend = Itprotech()
|
||||
dev = d.device
|
||||
if argv[0] == "show":
|
||||
dump = backend._dump(dev)
|
||||
for index, report in sorted(dump["keys"].items()):
|
||||
got = wire.decode(report)
|
||||
label = got[1].label() if got and got[1] else "(not decodable)"
|
||||
print(f" key{index:<3} {report[:8].hex(' ')} {label}")
|
||||
mode = dump["rgb"]
|
||||
name = wire.RGB_MODES[mode] if mode is not None and mode < len(wire.RGB_MODES) else "?"
|
||||
print(f" lighting: {mode} ({name}), colour "
|
||||
f"{'#%02x%02x%02x' % dump['colour'] if dump['colour'] else '?'}")
|
||||
return 0
|
||||
if not d.ready:
|
||||
print(d.headline)
|
||||
return 1
|
||||
if argv[0] == "rgb":
|
||||
if len(argv) < 2:
|
||||
return usage()
|
||||
want = argv[1].lower()
|
||||
mode = int(want) if want.isdigit() else (
|
||||
wire.RGB_MODES.index(want) if want in wire.RGB_MODES else None)
|
||||
if mode is None:
|
||||
print("Modes: " + ", ".join(f"{i} {m}" for i, m in enumerate(wire.RGB_MODES)))
|
||||
return 1
|
||||
backend.set_lighting(dev, mode)
|
||||
print(f"Lighting set to {mode} ({wire.RGB_MODES[mode]}).")
|
||||
return 0
|
||||
hexcol = argv[1].lstrip("#") if len(argv) > 1 else ""
|
||||
if len(hexcol) != 6:
|
||||
print("Give the colour as RRGGBB, e.g. ff8800")
|
||||
return 1
|
||||
rgb = tuple(int(hexcol[i:i + 2], 16) for i in (0, 2, 4))
|
||||
backend.set_lighting(dev, wire.RGB_CUSTOM, rgb)
|
||||
print(f"Custom colour #{hexcol} set, lighting mode {wire.RGB_CUSTOM}.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
sys.exit(_main(sys.argv[1:]))
|
||||
286
macropad_gui/backends/itprotech_proto.py
Normal file
286
macropad_gui/backends/itprotech_proto.py
Normal file
@@ -0,0 +1,286 @@
|
||||
"""
|
||||
Wire format of the ITPROTECH "MK" macro pads (USB vendor id 8808).
|
||||
|
||||
Sold in Japan as ちょい足しキーボード (MK-02, MK-04, MK-09 ...), and the same
|
||||
hardware turns up unbranded elsewhere. The vendor's Windows configurator is a
|
||||
.NET program (Run-me.exe, assembly yywlKey, namespace HID_SIMPLE); everything
|
||||
here was read out of that program and then checked against an MK-09
|
||||
(8808:660a) on a desk. Nothing is guessed from captures alone.
|
||||
|
||||
The config channel is the pad's fourth HID interface: usage page 0xFF00,
|
||||
64 byte input and output reports, no report id. Every report, both ways, is
|
||||
|
||||
len payload... padded with zeros to 64 bytes
|
||||
|
||||
where len counts the payload bytes.
|
||||
|
||||
Commands (host to pad):
|
||||
|
||||
01 55 read: the pad answers with one report per key
|
||||
(below), then 02 63 <rgb mode>, then
|
||||
04 64 <g> <r> <b> for the custom colour
|
||||
0d <key> <type> ... bind one key; 0x0d is the per-model payload
|
||||
length (13 on the 9 key pad, see DATA_LEN)
|
||||
02 57 <mode> lighting mode 0..14, see RGB_MODES
|
||||
04 70 <g> <r> <b> custom colour for mode 14. Green first. Really.
|
||||
01 56 enter the firmware bootloader. NEVER SEND THIS.
|
||||
|
||||
Key types:
|
||||
|
||||
01 keyboard <mods> <keycode>
|
||||
02 mouse <buttons> <x> <y> <wheel> (not offered here)
|
||||
03 media 04 <usage lo> <usage hi> 00 consumer page usage
|
||||
04 multikey <mods> <keycode> ... up to len-3 several keys, one
|
||||
modifier mask
|
||||
ff empty a slot the vendor software never wrote
|
||||
|
||||
One oddity shapes everything: the firmware acts on every OTHER output
|
||||
report it receives. Send a command once and it may or may not happen;
|
||||
send it twice, twenty milliseconds apart, and it happens exactly once.
|
||||
The vendor software sends everything twice. So does this module.
|
||||
"""
|
||||
|
||||
import macropad as proto
|
||||
|
||||
VID = "8808"
|
||||
PID_MK09 = "660a"
|
||||
CONFIRMED = (PID_MK09,)
|
||||
|
||||
REPORT_LEN = 65 # report id 0 + 64 payload bytes
|
||||
REPEAT = 2 # every report twice; the pad acts on every other one
|
||||
WRITE_GAP = 0.02 # the vendor's pause between the two copies
|
||||
|
||||
CMD_READ = 0x55
|
||||
CMD_BOOTLOADER = 0x56 # refused by frame(); documented so nobody tries
|
||||
CMD_RGB = 0x57
|
||||
CMD_COLOUR = 0x70
|
||||
|
||||
REPLY_RGB = 0x63 # 02 63 <mode>
|
||||
REPLY_COLOUR = 0x64 # 04 64 <g> <r> <b>
|
||||
|
||||
TYPE_NONE = 0x00
|
||||
TYPE_KEY = 0x01
|
||||
TYPE_MOUSE = 0x02
|
||||
TYPE_MEDIA = 0x03
|
||||
TYPE_MULTI = 0x04
|
||||
TYPE_EMPTY = 0xFF
|
||||
MEDIA_TAG = 0x04 # first body byte of a media binding
|
||||
|
||||
# Plain N key members of the family and the payload length each one uses,
|
||||
# as the vendor software has them. Only 660a has been seen; the rest are
|
||||
# here so detect() can name a sibling, and it still reads the real length
|
||||
# off the pad before writing anything.
|
||||
MODELS = {
|
||||
"6600": (1, 0x35, "1 key"),
|
||||
"6601": (2, 0x35, "2 keys"),
|
||||
"6602": (2, 0x35, "2 keys (mini)"),
|
||||
"6603": (3, 0x12, "3 keys"),
|
||||
"6604": (4, 0x12, "4 keys"),
|
||||
"6605": (5, 0x12, "5 keys"),
|
||||
"6606": (6, 0x12, "6 keys"),
|
||||
"6608": (7, 0x11, "7 keys"),
|
||||
"6609": (8, 0x0F, "8 keys"),
|
||||
PID_MK09: (9, 0x0D, "MK-09, 9 keys"),
|
||||
"660b": (12, 0x0A, "12 keys"),
|
||||
"6612": (10, 0x0A, "DIY 10 keys"),
|
||||
"6617": (8, 0x0C, "8 keys (new)"),
|
||||
}
|
||||
DATA_LEN = {pid: m[1] for pid, m in MODELS.items()}
|
||||
|
||||
RGB_MODES = [
|
||||
"off", "flow 1", "press: red", "press: green", "press: blue",
|
||||
"press: yellow", "press: cyan", "press: purple", "press: white",
|
||||
"flow 2", "flow 3", "irradiation", "colourful gradient", "table lamp",
|
||||
"custom colour",
|
||||
]
|
||||
RGB_CUSTOM = RGB_MODES.index("custom colour")
|
||||
|
||||
# Consumer usages the vendor software offers, so a key set there reads back
|
||||
# with a name. Standard HID consumer page codes; the pad passes them through.
|
||||
VENDOR_MEDIA = {
|
||||
0x00CD: "playpause", 0x00B7: "stop", 0x00B6: "prev", 0x00B5: "next",
|
||||
0x0194: "mycomputer", 0x006F: "brightnessup", 0x0070: "brightnessdown",
|
||||
0x0183: "mediaselect", 0x00E2: "mute", 0x00EA: "volumedown",
|
||||
0x00E9: "volumeup", 0x0192: "calculator", 0x0223: "wwwhome",
|
||||
0x018A: "mail", 0x0153: "bassdown", 0x0152: "bassup",
|
||||
0x0155: "trebledown", 0x0154: "trebleup", 0x0227: "refresh",
|
||||
0x0225: "forward", 0x0224: "back",
|
||||
}
|
||||
|
||||
|
||||
def media_name(usage):
|
||||
"""The CLI name for a consumer usage, or None if nobody has a word for it."""
|
||||
for name, code in proto.MEDIA_KEYS.items():
|
||||
if code == usage:
|
||||
return name
|
||||
return VENDOR_MEDIA.get(usage)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ frames
|
||||
|
||||
def frame(payload):
|
||||
"""One 65 byte report: report id 0, length byte, payload, zero padding."""
|
||||
payload = bytes(payload)
|
||||
if not payload or payload[0] == CMD_BOOTLOADER and len(payload) == 1:
|
||||
raise ValueError("refusing to send the bootloader command")
|
||||
if len(payload) > REPORT_LEN - 2:
|
||||
raise ValueError(f"payload of {len(payload)} bytes doesn't fit a report")
|
||||
body = bytes([len(payload)]) + payload
|
||||
return bytes([0]) + body + bytes(REPORT_LEN - 1 - len(body))
|
||||
|
||||
|
||||
def read_request():
|
||||
return frame([CMD_READ])
|
||||
|
||||
|
||||
def rgb_frame(mode):
|
||||
if not 0 <= mode < len(RGB_MODES):
|
||||
raise ValueError(f"lighting mode must be 0-{len(RGB_MODES) - 1}")
|
||||
return frame([CMD_RGB, mode])
|
||||
|
||||
|
||||
def colour_frame(r, g, b):
|
||||
for v in (r, g, b):
|
||||
if not 0 <= v <= 255:
|
||||
raise ValueError("colour components are 0-255")
|
||||
return frame([CMD_COLOUR, g, r, b]) # the pad wants green first
|
||||
|
||||
|
||||
def key_frame(data_len, index, kind, body):
|
||||
"""The report that binds one key. body is everything after the type."""
|
||||
if not 1 <= index <= 0xFE:
|
||||
raise ValueError(f"key index {index} out of range")
|
||||
payload = bytes([index, kind]) + bytes(body)
|
||||
if len(payload) > data_len:
|
||||
raise ValueError(f"{len(payload)} bytes won't fit this pad's "
|
||||
f"{data_len} byte key record")
|
||||
return frame(payload + bytes(data_len - len(payload)))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ encode
|
||||
|
||||
def parse_keys(text):
|
||||
"""
|
||||
'ctrl+c' -> (mods, [keycode]); 'a,b,c' -> (0, [a, b, c]).
|
||||
|
||||
Goes through macropad.MODIFIERS and macropad.KEYCODES like the CLI, so a
|
||||
binding typed once means the same on every family of pad. A sequence
|
||||
needs the same modifiers on every step, because that is all the pad can
|
||||
store: one modifier byte, then up to ten keycodes.
|
||||
"""
|
||||
steps = proto.parse_keystroke(text)
|
||||
mods = {m for m, _ in steps}
|
||||
if len(mods) > 1:
|
||||
raise ValueError("this pad holds one set of modifiers for the whole "
|
||||
"sequence, so every step must use the same ones")
|
||||
codes = [c for _, c in steps]
|
||||
if len(codes) > 1 and 0 in codes:
|
||||
raise ValueError("every step of a sequence needs a key, not just "
|
||||
"modifiers")
|
||||
return steps[0][0], codes
|
||||
|
||||
|
||||
def encode(binding, data_len):
|
||||
"""
|
||||
(type, body) for one Binding, or ValueError saying why the pad can't
|
||||
hold it. Nothing here touches hardware.
|
||||
"""
|
||||
if binding.none:
|
||||
return TYPE_KEY, bytes([0, 0])
|
||||
if binding.media:
|
||||
usage = proto.MEDIA_KEYS.get(binding.media)
|
||||
if usage is None:
|
||||
raise ValueError(f"don't know the media key {binding.media!r}")
|
||||
return TYPE_MEDIA, bytes([MEDIA_TAG, usage & 0xFF, usage >> 8, 0])
|
||||
if binding.delay:
|
||||
raise ValueError("this pad has no delay between keys")
|
||||
mods, codes = parse_keys(binding.keys)
|
||||
if len(codes) == 1:
|
||||
return TYPE_KEY, bytes([mods, codes[0]])
|
||||
room = data_len - 3
|
||||
if len(codes) > room:
|
||||
raise ValueError(f"this pad holds at most {room} keys in a sequence")
|
||||
return TYPE_MULTI, bytes([mods]) + bytes(codes)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ decode
|
||||
|
||||
def _names(mods, code, core):
|
||||
names = [n for n, bit in core._MOD_ORDER if mods & bit]
|
||||
if code:
|
||||
name = core._KEYNAME_BY_CODE.get(code)
|
||||
if name is None:
|
||||
return None
|
||||
names.append(name)
|
||||
return "+".join(names)
|
||||
|
||||
|
||||
def decode(report):
|
||||
"""
|
||||
(index, Binding or None) from one key report, or None if the report
|
||||
isn't a key record at all. None for the binding means "a key we can see
|
||||
but can't name": a mouse action, or a slot the vendor never wrote.
|
||||
"""
|
||||
from .. import core
|
||||
if len(report) < 4 or report[0] < 4:
|
||||
return None
|
||||
data_len = report[0]
|
||||
index, kind = report[1], report[2]
|
||||
body = report[3:data_len + 1]
|
||||
if kind in (TYPE_NONE,) or (kind == TYPE_KEY and body[0] == 0 and body[1] == 0):
|
||||
return index, core.Binding(none=True)
|
||||
if kind == TYPE_KEY:
|
||||
text = _names(body[0], body[1], core)
|
||||
return index, core.Binding(keys=text) if text else None
|
||||
if kind == TYPE_MEDIA and body[0] == MEDIA_TAG:
|
||||
name = media_name(body[1] | (body[2] << 8))
|
||||
return index, core.Binding(media=name) if name else None
|
||||
if kind == TYPE_MULTI:
|
||||
mods = body[0]
|
||||
codes = [c for c in body[1:] if c not in (0, TYPE_EMPTY)]
|
||||
steps = [_names(mods, c, core) for c in codes]
|
||||
if not steps or None in steps:
|
||||
return index, None
|
||||
return index, core.Binding(keys=",".join(steps))
|
||||
return index, None
|
||||
|
||||
|
||||
def is_key_report(report):
|
||||
return (len(report) >= 4 and report[0] >= 4
|
||||
and report[0] not in (2, 4) and 1 <= report[1] <= 0xFE)
|
||||
|
||||
|
||||
def parse_dump(replies):
|
||||
"""
|
||||
What a read returned, sorted out: {"keys": {index: report},
|
||||
"data_len": int or None, "rgb": mode or None, "colour": (r, g, b) or None}.
|
||||
|
||||
The rgb and colour replies carry stale bytes after the ones that matter,
|
||||
so only those are read.
|
||||
"""
|
||||
out = {"keys": {}, "data_len": None, "rgb": None, "colour": None}
|
||||
for r in replies:
|
||||
if len(r) < 3:
|
||||
continue
|
||||
if r[0] == 2 and r[1] == REPLY_RGB:
|
||||
out["rgb"] = r[2]
|
||||
elif r[0] == 4 and r[1] == REPLY_COLOUR and len(r) >= 5:
|
||||
out["colour"] = (r[3], r[2], r[4])
|
||||
elif is_key_report(r):
|
||||
out["keys"][r[1]] = bytes(r)
|
||||
out["data_len"] = r[0]
|
||||
return out
|
||||
|
||||
|
||||
def consistent(dump):
|
||||
"""Why a dump isn't a clean picture of one pad, or '' if it is."""
|
||||
keys = dump["keys"]
|
||||
if not keys:
|
||||
return "it sent no key records"
|
||||
lengths = {r[0] for r in keys.values()}
|
||||
if len(lengths) != 1:
|
||||
return "its key records disagree about their own length"
|
||||
n = len(keys)
|
||||
if set(keys) != set(range(1, n + 1)):
|
||||
return f"it reported keys {sorted(keys)}, not 1 to {n}"
|
||||
return ""
|
||||
@@ -211,6 +211,7 @@ class Layout:
|
||||
# covers several different pads.
|
||||
KNOWN_LAYOUTS = {
|
||||
("1189", "8840"): Layout(12, 2, 3, 4, "12 keys, 2 knobs"),
|
||||
("8808", "660a"): Layout(9, 0, 3, 3, "ITPROTECH MK-09, 9 keys"),
|
||||
}
|
||||
DEFAULT_LAYOUT = KNOWN_LAYOUTS[(VID, PID)]
|
||||
|
||||
@@ -569,7 +570,8 @@ def _matching_rules():
|
||||
s = line.strip()
|
||||
if s.startswith("#"):
|
||||
continue
|
||||
if re.search(rf'idVendor\}}=="{VID}"', s, re.I) and "uaccess" in s:
|
||||
if "uaccess" in s and any(
|
||||
re.search(rf'idVendor\}}=="{v}"', s, re.I) for v in vendors()):
|
||||
hits.append(f)
|
||||
break
|
||||
return hits
|
||||
@@ -698,7 +700,9 @@ def diagnose(keyd=True):
|
||||
"macropad-probe.py and check the interface list."],
|
||||
warnings=warnings)
|
||||
return Diagnosis("unplugged", "No pad connected.",
|
||||
detail=[f"Looking for {DEVICE_ID} on USB."],
|
||||
detail=["Looking for " + ", ".join(
|
||||
f"{v}:{'/'.join(p) or '*'}" for v, p, _ in backends().known_ids())
|
||||
+ " on USB."],
|
||||
warnings=warnings)
|
||||
|
||||
d = found[0]
|
||||
@@ -862,10 +866,13 @@ class ReadError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _exchange(path, request, want=None):
|
||||
def _exchange(path, request, want=None, repeat=1):
|
||||
"""
|
||||
Send one request and collect replies until the pad goes quiet.
|
||||
|
||||
repeat sends the same request that many times, WRITE_GAP apart, for
|
||||
firmware that acts on every other report (the ITPROTECH pads).
|
||||
|
||||
Reading is the one place we write to the pad without changing it: both
|
||||
commands here are queries, byte for byte what the vendor software sends.
|
||||
"""
|
||||
@@ -884,7 +891,10 @@ def _exchange(path, request, want=None):
|
||||
except OSError:
|
||||
break
|
||||
try:
|
||||
os.write(fd, request)
|
||||
for i in range(repeat):
|
||||
if i:
|
||||
time.sleep(WRITE_GAP)
|
||||
os.write(fd, request)
|
||||
except OSError as e:
|
||||
raise ReadError(_explain(e)) from e
|
||||
|
||||
|
||||
Reference in New Issue
Block a user