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:
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:]))
|
||||
Reference in New Issue
Block a user