Files
macropad-itprotech/macropad_gui/backends/ch57x.py
Bjorn Blomberg 80c6a5da1c 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>
2026-10-23 19:24:30 +02:00

222 lines
8.5 KiB
Python

"""
The CH57x family: 1189:8840 and its relatives.
Reverse engineered from USB captures of the vendor's own software, and every
packet in tests/captures is replayed by the test suite. The wire format lives
in macropad.py and is not to be edited without a capture to back it up.
Two things about this firmware shape the code:
* It keeps 15 key slots and 3 knob slots whatever hardware is soldered on,
so a layer read always answers 24 slots. Only the 0xFB query says how
many of those are real.
* There is no addressing by name. A control is a slot number: keys are
1 to 15, knobs start at 0x10 and take three apiece.
"""
import errno
import time
import macropad as proto
from .base import Backend
WRITE_GAP = 0.02 # the same pause the CLI leaves between reports
DIAL_ACTIONS = ("left", "push", "right")
class CH57x(Backend):
key = "ch57x"
name = "CH57x macro pad"
vid = "1189"
pids = ("8840",) # confirmed; siblings share the vendor id, not the
# protocol, so they go through detect() first
layers = proto.LAYERS
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):
"""
Keys count from 1; knobs start at 0x10, three apiece. Confirmed on
1189:8840 both by writing and by reading back, and it matches the
slots the pad reports for itself.
"""
control = control.lower()
if control.startswith("key"):
return int(control[3:])
dial, act = control.split("-")
return proto.KNOB_BASE + (int(dial[4:]) - 1) * 3 + DIAL_ACTIONS.index(act)
# -------------------------------------------------------------- decode
def decode_info(self, report):
"""(keys, knobs) from a 0xFB reply, or None."""
if len(report) < 4 or report[1] != proto.MAGIC_INFO:
return None
return report[2], report[3]
def decode_config(self, report, layout=None):
"""
(control, layer, Binding) from a config report, or None.
layout decides which slots count as real controls, and it matters
more than it looks: decode a 15 key pad against a 12 key layout and
keys 13 to 15 and the third knob vanish without a word.
"""
from .. import core
if len(report) < 13 or report[1] not in (proto.MAGIC_NATIVE,
proto.MAGIC_READ):
return None
action, layer, kind = report[2], report[3], report[4]
controls = (layout or core.DEFAULT_LAYOUT).control_for_action
control = controls.get(action)
if control is None:
return None
delay = report[5] | (report[6] << 8)
count = report[10]
if kind == proto.KeyType.MULTIMEDIA and count:
media = core._MEDIA_BY_USAGE.get(report[11] | (report[12] << 8))
return (control, layer, core.Binding(media=media)) if media else None
if count == 0 and kind in (proto.KeyType.NONE, proto.KeyType.BASIC):
mods, code = report[11], report[12]
if not mods and not code:
return control, layer, core.Binding(none=True)
return None
if kind in (proto.KeyType.NONE, proto.KeyType.BASIC) and count:
steps = []
for i in range(count):
off = 11 + 2 * i
if off + 1 >= len(report):
return None
mods, code = report[off], report[off + 1]
names = [n for n, bit in core._MOD_ORDER if mods & bit]
if code:
if code not in core._KEYNAME_BY_CODE:
return None
names.append(core._KEYNAME_BY_CODE[code])
steps.append("+".join(names))
return control, layer, core.Binding(keys=",".join(steps), delay=delay)
return None
# ---------------------------------------------------------------- read
def info(self, device):
from .. import core
for reply in core._exchange(device.path,
proto.native_info(device.report_id), want=1):
got = self.decode_info(reply)
if got:
return got
return None
def read_layer(self, device, layer=1, layout=None):
from .. import core
out = {}
request = proto.native_read(device.report_id, layer)
for reply in core._exchange(device.path, request):
decoded = self.decode_config(reply, layout)
if not decoded:
continue
control, got_layer, binding = decoded
if got_layer == layer:
out[control] = binding
return out
# --------------------------------------------------------------- write
def write(self, device, control, binding, state, layer=1, _write=None):
"""
Send one binding (config report, then commit) and update the shadow
state. On success the state file is already saved.
"""
from .. import core
write = _write or proto.write_report
reports = binding.reports(device.report_id, self.action_byte(control),
layer)
sent = 0
try:
for r in reports:
write(device.path, r)
sent += 1
time.sleep(WRITE_GAP)
except OSError as e:
why = core._explain(e)
# if the very first write failed on open, nothing reached the pad
untouched = sent == 0 and e.errno in (errno.ENOENT, errno.ENODEV,
errno.EACCES, errno.EPERM)
if not untouched:
state.mark_unknown(control, f"write interrupted after {sent} of "
f"{len(reports)} reports: {why}", layer)
state.save()
raise core.WriteError(f"{control}: {why}",
pad_touched=not untouched, cause=e)
state.record(control, binding, layer)
state.save()
# -------------------------------------------------------------- detect
def detect(self, device):
"""
Ask an unknown pad what it is, without configuring anything.
Both commands are queries. We only conclude the pad speaks this
protocol if it answers both in the right shape: a well formed info
reply, and a layer whose controls are exactly the slots that reply
implies. A pad from another family fails one of those and we leave
it alone.
"""
from .. import core
try:
info = self.info(device)
except core.ReadError as e:
return core.Detection(why=f"it didn't answer: {e}")
if not info:
return core.Detection(why="it didn't answer the 'what are you' query")
keys, knobs = info
if not self.supports(keys, knobs):
return core.Detection(why=f"it reported {keys} keys and {knobs} "
"knobs, which isn't a pad this protocol "
"can describe")
probe = core.Layout(keys, knobs, rows=keys, cols=1) # geometry unused
expected = set(probe.control_for_action)
try:
replies = core._exchange(device.path,
proto.native_read(device.report_id, 1))
except core.ReadError as e:
return core.Detection(
why=f"it answered the first query but not the second: {e}")
seen, bindings = set(), {}
for reply in replies:
if len(reply) < 13 or reply[1] != proto.MAGIC_READ:
continue
action, layer = reply[2], reply[3]
seen.add(action)
if action not in expected or layer != 1:
continue
decoded = self.decode_config(reply, probe)
if decoded:
bindings[probe.control_for_action[action]] = decoded[2]
missing = expected - seen
if missing:
return core.Detection(
why=f"it described {keys} keys and {knobs} knobs but didn't "
f"report {len(missing)} of them, so this isn't the "
"protocol it speaks")
return core.Detection(speaks=True, keys=keys, knobs=knobs,
layers=self.layers, bindings=bindings)