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.
288 lines
11 KiB
Python
288 lines
11 KiB
Python
"""
|
|
SayoDevice backend. Complete but never run against real hardware.
|
|
|
|
Drop this and sayo_proto.py in macropad_gui/backends/ and add one line to
|
|
REGISTRY in backends/__init__.py:
|
|
|
|
from .sayo import Sayo
|
|
REGISTRY = (CH57x(), Sayo())
|
|
|
|
Until it is in REGISTRY the app cannot see it, so this file sitting here
|
|
changes nothing for anyone.
|
|
|
|
The protocol is in sayo_proto.py, decoded from WebHID captures of
|
|
sayodevice.com and checked against them line by line. Three things about
|
|
it shape the code here:
|
|
|
|
* Reads and writes are the same command, 0x12. A four byte payload asks
|
|
for a row of a table, a sixty byte payload is the row coming back or
|
|
going in. Table 0x10 is the keys.
|
|
* The pad says how many keys it has. Walk the index up and it answers
|
|
with a no-such-index flag, so the key count is discovered rather than
|
|
configured. It reports where each key physically is, too, so the grid
|
|
is known rather than guessed.
|
|
* Several bytes of a key's record drift on their own between reads, so
|
|
a write is read-modify-write: read the record, change the two binding
|
|
bytes, send it back. The vendor's own page does the same, which one
|
|
capture caught it doing.
|
|
|
|
The pad echoes a write back about 100ms later, which is a real
|
|
acknowledgement. The CH57x says nothing, so this backend can confirm a
|
|
write where the other one can only hope.
|
|
"""
|
|
|
|
import errno
|
|
|
|
from . import sayo_proto as wire
|
|
from .base import Backend
|
|
|
|
REPORT_ID = wire.REPORT_ID
|
|
MAX_KEYS = 64 # a sanity stop in case a pad never says no
|
|
|
|
|
|
class Sayo(Backend):
|
|
key = "sayo"
|
|
name = "SayoDevice"
|
|
vid = "8089"
|
|
pids = ("000b",) # the 2x4; other Sayo pids are untested
|
|
layers = 1 # the firmware has more, we expose one for now
|
|
max_keys = MAX_KEYS
|
|
max_knobs = 0
|
|
|
|
# ------------------------------------------------------------ protocol
|
|
|
|
def action_byte(self, control):
|
|
"""Keys are their own index, counting from zero. No knobs."""
|
|
if control.startswith("key"):
|
|
return int(control[3:]) - 1
|
|
raise ValueError(f"{self.name} has no knobs: {control}")
|
|
|
|
def _request(self, table, index=0):
|
|
return wire.report(wire.read_request(table, index))
|
|
|
|
def _ask(self, device, table, index=0):
|
|
"""
|
|
One row of one table, or None if the pad has no such row.
|
|
|
|
Heartbeats arrive unasked about once a second and have to be
|
|
stepped over, or the first one to land looks like the answer.
|
|
"""
|
|
from .. import core
|
|
for reply in core._exchange(device.path, self._request(table, index),
|
|
want=4):
|
|
if len(reply) < 2 or reply[0] != REPORT_ID:
|
|
continue
|
|
payload = reply[1:]
|
|
if wire.is_heartbeat(payload):
|
|
continue
|
|
try:
|
|
got = wire.parse_reply(payload)
|
|
except ValueError:
|
|
continue
|
|
if got["table"] != table or got["index"] != index:
|
|
continue
|
|
return None if got["end"] else payload
|
|
return None
|
|
|
|
def _records(self, device, limit=MAX_KEYS):
|
|
"""Every key's record, in order, stopping where the pad says to."""
|
|
out = []
|
|
for i in range(limit):
|
|
rec = self._ask(device, wire.TABLE_KEYS, i)
|
|
if rec is None or not wire.is_key_record(rec):
|
|
break
|
|
out.append(rec)
|
|
return out
|
|
|
|
def _binding(self, record):
|
|
"""
|
|
core.Binding for one key, or None if we cannot say what it does.
|
|
|
|
None is the honest answer for a key type no capture covers, and
|
|
it matters: three keys on one real pad are type 0x80, and reading
|
|
those bytes as a modifier made them look like a bare "ctrl+shift"
|
|
that nobody had set. The app shows None as unknown, which is true,
|
|
and write() then refuses to overwrite it.
|
|
"""
|
|
from .. import core
|
|
kind, a, b = wire.read_binding(record)
|
|
|
|
if kind == "consumer":
|
|
return core.Binding(media=b) if b else None
|
|
|
|
if kind != "keyboard":
|
|
return None
|
|
|
|
mods, code = a, b
|
|
if not mods and not code:
|
|
return core.Binding(none=True)
|
|
names = [n for n, bit in core._MOD_ORDER if mods & bit]
|
|
if code:
|
|
name = core._KEYNAME_BY_CODE.get(code)
|
|
if name is None:
|
|
return None
|
|
names.append(name)
|
|
return core.Binding(keys="+".join(names))
|
|
|
|
# --------------------------------------------------------------- read
|
|
|
|
def info(self, device):
|
|
"""
|
|
(keys, knobs) as the pad reports them, or None if it goes quiet.
|
|
|
|
None matters: it means "did not answer", and the app then treats
|
|
the pad as unsupported rather than writing to it.
|
|
"""
|
|
records = self._records(device)
|
|
return (len(records), 0) if records else None
|
|
|
|
def model(self, device):
|
|
"""The name the pad calls itself, for the window title. May be ''."""
|
|
reply = self._ask(device, wire.TABLE_NAME)
|
|
return wire.device_name(reply) if reply else ""
|
|
|
|
def read_layer(self, device, layer=1, layout=None):
|
|
out = {}
|
|
for i, rec in enumerate(self._records(device)):
|
|
binding = self._binding(rec)
|
|
if binding is not None:
|
|
out[f"key{i + 1}"] = binding
|
|
return out
|
|
|
|
# -------------------------------------------------------------- write
|
|
|
|
def write(self, device, control, binding, state, layer=1, _write=None):
|
|
"""
|
|
Put one binding on the pad.
|
|
|
|
Read the key's record first, because most of it is the pad's own
|
|
and some of it moves between reads. If the read fails nothing has
|
|
been sent, which is the one case where the app can promise the old
|
|
binding is still there.
|
|
"""
|
|
from .. import core
|
|
import macropad as proto
|
|
|
|
index = self.action_byte(control)
|
|
|
|
try:
|
|
record = self._ask(device, wire.TABLE_KEYS, index)
|
|
except core.ReadError as e:
|
|
raise core.WriteError(f"{control}: couldn't read the key first, so "
|
|
f"nothing was changed ({e})",
|
|
pad_touched=False, cause=e)
|
|
if record is None or not wire.is_key_record(record):
|
|
raise core.WriteError(f"{control}: the pad has no key {index + 1}",
|
|
pad_touched=False)
|
|
|
|
# Refuse to write over a key whose current type no capture covers.
|
|
# Overwriting it would leave a key marked as one kind holding
|
|
# another kind's bytes, and what the firmware does then is anyone's
|
|
# guess. Better to say so and leave the pad alone.
|
|
kind = wire.kind_of(record)
|
|
if kind == "unknown":
|
|
raise core.WriteError(
|
|
f"{control}: this key is set to something this app hasn't "
|
|
f"decoded yet (type 0x{record[wire.KIND_AT]:02x}), so it was "
|
|
f"left alone. Clear it in the SayoDevice configurator first "
|
|
f"and it becomes editable here.", pad_touched=False)
|
|
|
|
payload = self._payload(record, binding)
|
|
write = _write or proto.write_report
|
|
try:
|
|
write(device.path, wire.report(payload))
|
|
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)
|
|
state.record(control, binding, layer)
|
|
state.save()
|
|
|
|
def _payload(self, record, binding):
|
|
"""The sealed write for this binding, on this key's own record."""
|
|
from .. import core
|
|
|
|
if binding.media:
|
|
if binding.media not in wire.CONSUMER_BY_NAME:
|
|
known = ", ".join(sorted(wire.CONSUMER_BY_NAME))
|
|
raise core.WriteError(
|
|
f"{self.name}: {binding.media} isn't captured yet. This "
|
|
f"pad numbers media keys its own way, not by HID code, so "
|
|
f"each one needs seeing once. Known so far: {known}.",
|
|
pad_touched=False)
|
|
return wire.patch_consumer(record, binding.media)
|
|
return wire.patch_binding(record, *self._encode(binding))
|
|
|
|
def _encode(self, binding):
|
|
"""
|
|
(mods, usage) for one keyboard binding, or a WriteError saying why
|
|
not.
|
|
|
|
Parsing goes through macropad.MODIFIERS and macropad.KEYCODES, the
|
|
same tables the CLI uses, so a binding typed once means the same
|
|
thing on either family of pad. That is most of the point of having
|
|
a backend seam at all.
|
|
"""
|
|
from .. import core
|
|
import macropad as proto
|
|
|
|
if binding.none:
|
|
return 0, 0
|
|
if "," in binding.keys:
|
|
raise core.WriteError(
|
|
f"{self.name}: this pad holds one keystroke per key, not a "
|
|
"sequence", pad_touched=False)
|
|
mods, code = 0, 0
|
|
for part in binding.keys.lower().split("+"):
|
|
part = part.strip()
|
|
if not part:
|
|
continue
|
|
if part in proto.MODIFIERS:
|
|
mods |= proto.MODIFIERS[part]
|
|
elif part in proto.KEYCODES:
|
|
if code:
|
|
raise core.WriteError(
|
|
f"{self.name}: this pad holds one key plus modifiers, "
|
|
f"so {binding.keys!r} won't fit", pad_touched=False)
|
|
code = proto.KEYCODES[part]
|
|
else:
|
|
raise core.WriteError(f"{self.name}: don't know the key "
|
|
f"{part!r}", pad_touched=False)
|
|
return mods, code
|
|
|
|
# ------------------------------------------------------------- detect
|
|
|
|
def detect(self, device):
|
|
"""
|
|
Ask an unknown pad what it is, reading only.
|
|
|
|
Two things have to line up before we call it a Sayo: a key table
|
|
that answers in the right shape, and a model name. A pad sharing
|
|
the vendor id but not the protocol fails one of them and is left
|
|
alone.
|
|
"""
|
|
from .. import core
|
|
try:
|
|
records = self._records(device)
|
|
except core.ReadError as e:
|
|
return core.Detection(why=f"it didn't answer: {e}")
|
|
if not records:
|
|
return core.Detection(why="it didn't answer the key table query")
|
|
|
|
rows, cols = wire.grid_from_records(records)
|
|
if rows * cols != len(records):
|
|
rows, cols = 1, len(records) # odd shape; one row is honest
|
|
bindings = {}
|
|
for i, rec in enumerate(records):
|
|
got = self._binding(rec)
|
|
if got is not None:
|
|
bindings[f"key{i + 1}"] = got
|
|
return core.Detection(speaks=True, keys=len(records), knobs=0,
|
|
layers=self.layers, bindings=bindings,
|
|
grid=(rows, cols), model=self.model(device))
|