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>
222 lines
8.5 KiB
Python
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)
|