Running the backend module directly made runpy warn, since the package had already imported it. The lighting commands now sit with doctor, state and write. A pad without knobs lies flat on the desk, so it is drawn flat unless an orientation has been chosen. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
197 lines
7.7 KiB
Python
197 lines
7.7 KiB
Python
"""
|
|
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.
|
|
|
|
Lighting and colour have no place in the GUI yet; they are a subcommand:
|
|
|
|
macropad lighting show
|
|
macropad lighting rgb "press: blue"
|
|
macropad lighting 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}")
|
|
|
|
|