Files
macropad-itprotech/macropad_gui/backends/itprotech.py
Bjorn Blomberg 625c95f09c Lighting menu in the window for pads that have backlight modes
A backend that lists lighting_modes gets a Lighting button in the footer:
one entry per mode and a colour picker for the custom one. The tick
follows what the pad reported in its last read, since every read of an
ITPROTECH pad carries the lighting along with the keys.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-23 19:37:44 +02:00

214 lines
8.6 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 is pad wide rather than per key, so the GUI offers it in a
Lighting menu at the foot of the window, and the CLI as 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
lighting_modes = wire.RGB_MODES # offered by the GUI's Lighting menu
lighting_custom = wire.RGB_CUSTOM # the mode that takes a colour
def __init__(self):
self._data_len = {} # device path -> key record length seen
self._lighting = {} # device path -> (mode, colour) last read
# ------------------------------------------------------------ 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"]
if dump["rgb"] is not None:
self._lighting[device.path] = (dump["rgb"], dump["colour"])
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"]
def last_lighting(self, device):
"""
(mode, colour) from the most recent read of this pad, or None.
Every read returns the lighting along with the keys, so the GUI can
show it without asking the pad again.
"""
return self._lighting.get(device.path)
# --------------------------------------------------------------- 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)
old = self._lighting.get(device.path, (None, None))
self._lighting[device.path] = (mode, colour if colour is not None else old[1])
# -------------------------------------------------------------- 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}")