Files
Mohamed Mahjoub 6a419eb3b6 Add SayoDevice support, 15 key pads and theme styles
Second protocol family (8089:000b) behind a new backend seam, 15 key and 3 knob pads, Knobs North plus the style and colour grid, Windows import fix, window resize fix.
2026-10-08 22:14:31 +01:00

217 lines
8.4 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
# ------------------------------------------------------------ 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)