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.
This commit is contained in:
34
macropad_gui/backends/__init__.py
Normal file
34
macropad_gui/backends/__init__.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""
|
||||
One device family per backend.
|
||||
|
||||
Everything above this line works in terms of a Layout and a set of control
|
||||
ids and doesn't care what the hardware speaks. Everything below it is one
|
||||
pad family's protocol. Adding a family means writing the six methods in
|
||||
base.py and listing the class here.
|
||||
"""
|
||||
|
||||
from .ch57x import CH57x
|
||||
from .sayo import Sayo
|
||||
|
||||
REGISTRY = (CH57x(), Sayo()) # tried in order; first to claim a device wins
|
||||
DEFAULT = REGISTRY[0]
|
||||
|
||||
|
||||
def for_vid_pid(vid, pid):
|
||||
for backend in REGISTRY:
|
||||
if backend.claims(str(vid).lower(), str(pid).lower()):
|
||||
return backend
|
||||
return None
|
||||
|
||||
|
||||
def for_device(device):
|
||||
# Falls back rather than returning None, so callers that only ever see
|
||||
# one family stay simple. Use for_vid_pid when "nobody owns this" is an
|
||||
# answer you need to act on.
|
||||
if device is None:
|
||||
return DEFAULT
|
||||
return for_vid_pid(device.vid, device.pid) or DEFAULT
|
||||
|
||||
|
||||
def known_ids():
|
||||
return [(b.vid, b.pids, b.name) for b in REGISTRY]
|
||||
64
macropad_gui/backends/base.py
Normal file
64
macropad_gui/backends/base.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
What a device family has to provide.
|
||||
|
||||
These raise rather than returning something vague, so a half written backend
|
||||
fails at the call it can't answer instead of writing wrong bytes to somebody's
|
||||
hardware.
|
||||
"""
|
||||
|
||||
|
||||
class Backend:
|
||||
key = "" # short id, used in settings and messages
|
||||
name = "" # what a person would call this family
|
||||
vid = "" # USB vendor id, lowercase hex, no 0x
|
||||
pids = () # product ids this backend is CONFIRMED on
|
||||
layers = 1
|
||||
max_keys = 0
|
||||
max_knobs = 0
|
||||
|
||||
def claims(self, vid, pid):
|
||||
"""True only for ids this backend has been confirmed on."""
|
||||
if vid.lower() != self.vid.lower():
|
||||
return False
|
||||
return not self.pids or pid.lower() in {p.lower() for p in self.pids}
|
||||
|
||||
def could_claim(self, vid, pid):
|
||||
# Siblings share a vendor id but not always a protocol. The scan and
|
||||
# the udev rule cover the whole vendor so an unconfirmed sibling is
|
||||
# still found and offered detection; claims() stays strict so it is
|
||||
# never written to on the strength of its vendor id alone.
|
||||
return vid.lower() == self.vid.lower()
|
||||
|
||||
def udev_rule(self):
|
||||
return (f'KERNEL=="hidraw*", ATTRS{{idVendor}}=="{self.vid}", '
|
||||
f'TAG+="uaccess"')
|
||||
|
||||
def adopt(self, layout):
|
||||
"""Where a family can say more about a shape than detection found."""
|
||||
return layout
|
||||
|
||||
def supports(self, keys, knobs):
|
||||
return 1 <= keys <= self.max_keys and 0 <= knobs <= self.max_knobs
|
||||
|
||||
# The six a family must answer.
|
||||
|
||||
def action_byte(self, control):
|
||||
"""The slot number this family uses for a control id."""
|
||||
raise NotImplementedError
|
||||
|
||||
def info(self, device):
|
||||
"""(keys, knobs) as the pad reports them, or None."""
|
||||
raise NotImplementedError
|
||||
|
||||
def read_layer(self, device, layer=1, layout=None):
|
||||
raise NotImplementedError
|
||||
|
||||
def write(self, device, control, binding, state, layer=1, _write=None):
|
||||
raise NotImplementedError
|
||||
|
||||
def detect(self, device):
|
||||
"""Read only. Must say no rather than guess."""
|
||||
raise NotImplementedError
|
||||
|
||||
def __repr__(self):
|
||||
return f"<{type(self).__name__} {self.vid}:{'/'.join(self.pids) or '*'}>"
|
||||
216
macropad_gui/backends/ch57x.py
Normal file
216
macropad_gui/backends/ch57x.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""
|
||||
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)
|
||||
287
macropad_gui/backends/sayo.py
Normal file
287
macropad_gui/backends/sayo.py
Normal file
@@ -0,0 +1,287 @@
|
||||
"""
|
||||
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))
|
||||
631
macropad_gui/backends/sayo_proto.py
Normal file
631
macropad_gui/backends/sayo_proto.py
Normal file
@@ -0,0 +1,631 @@
|
||||
"""
|
||||
SayoDevice 8089:000b wire format.
|
||||
|
||||
Worked out from WebHID captures of sayodevice.com. Everything here is
|
||||
checked against those captures by the self test at the bottom: run this
|
||||
file directly and it rebuilds every captured packet byte for byte.
|
||||
|
||||
python3 sayo_proto.py
|
||||
|
||||
A report is 64 bytes: report id 0x21, then 63 bytes of payload. One
|
||||
command byte, 0x12, covers both directions. A four byte payload is a
|
||||
question, a sixty byte payload is an answer or a write.
|
||||
|
||||
payload
|
||||
0 0x12 the only command we have seen
|
||||
1-2 checksum, little endian (see checksum())
|
||||
3 how many bytes from byte 4 on carry meaning. 0x04 for a
|
||||
read request, 0x3c for a key record
|
||||
4 flags. 0x00 asking, 0x40 in a reply means no such index
|
||||
5 which table (see TABLES)
|
||||
6 index within that table
|
||||
7+ the data
|
||||
|
||||
Table 0x10 is the keys, one record per key, and a record looks like this:
|
||||
|
||||
7 0x01
|
||||
8-10 0x00
|
||||
11-18 x, y, width, height on the pad face, four little endian 16
|
||||
bit numbers. Key 1 of an 8 key pad is at 1250,1250 and is
|
||||
1800 square, giving a 4 by 2 grid with a pitch of 1900
|
||||
19 0x64 100
|
||||
23 what kind of key this is (see KINDS). It decides what bytes
|
||||
27 and 28 mean, and the configurator writes it
|
||||
27-28 the binding, read according to byte 23
|
||||
44 a counter of some sort, drifts on its own
|
||||
51-52 also drifts on its own, probably a press count or timestamp
|
||||
|
||||
Byte 23 took two captures to pin down. A capture of the page setting
|
||||
volume and brightness keys shows it going from 0x01 to 0x03 and byte 27
|
||||
carrying a small code, while an earlier capture of the page setting a
|
||||
plain letter left it alone. So it is the key's type, not drifting state:
|
||||
|
||||
0x00, 0x01 a keyboard shortcut. 27 is the modifier bitmask,
|
||||
standard HID, and 28 is the usage code. The low bit
|
||||
appears either way and is something else, so it is
|
||||
preserved rather than understood
|
||||
0x03 volume and brightness. 27 is a code of Sayo's own
|
||||
(see CONSUMER), 28 is zero
|
||||
anything a kind we have not captured. On one real pad, 0x80
|
||||
else is three media transport keys. Read as unknown and
|
||||
never written over
|
||||
|
||||
Writing is read-modify-write: read the key's record, set bytes 23, 27
|
||||
and 28, reseal. The vendor's own page does exactly that, which one
|
||||
capture caught in the act. Nothing else in the record is ours to touch,
|
||||
because bytes 44 and 51-52 really do move on their own.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
REPORT_ID = 0x21
|
||||
CMD = 0x12 # both directions, both reads and writes
|
||||
KEY_AT = 6
|
||||
KIND_AT = 23 # what sort of key it is, which decides 27 and 28
|
||||
MOD_AT = 27
|
||||
KEYCODE_AT = 28
|
||||
CHECK_AT = 1 # payload bytes 1 and 2, little endian
|
||||
LEN_AT = 3
|
||||
FLAG_AT = 4
|
||||
TABLE_AT = 5
|
||||
DATA_AT = 7
|
||||
|
||||
RECORD_LEN = 0x3C # byte 3 of a key record
|
||||
REQUEST_LEN = 0x04 # byte 3 of a read request
|
||||
NO_SUCH_INDEX = 0x40 # byte 4 of a reply, meaning you ran off the end
|
||||
|
||||
# Byte 5. The pad lists the ones it has in table 0x00; these are the ones
|
||||
# we have a use for.
|
||||
TABLE_DIRECTORY = 0x00
|
||||
TABLE_NAME = 0x01 # the model name, UTF-16LE
|
||||
TABLE_INFO = 0x02 # vid and pid at bytes 19 to 22, and uptime
|
||||
TABLE_KEYS = 0x10 # one record per key, the ones we care about
|
||||
|
||||
# Table 0x01 and 0x19 want 0x0c in the flag byte rather than 0x00. Why is
|
||||
# not clear, but the page is consistent about it and so are we.
|
||||
FLAGS = {TABLE_NAME: 0x0C}
|
||||
|
||||
CMD_WRITE_KEY = CMD # old name, kept so nothing outside here breaks
|
||||
|
||||
# Byte 23. KEYBOARD_KINDS are the two values seen alongside a real
|
||||
# keyboard shortcut; the low bit is some other flag we carry along.
|
||||
KIND_KEYBOARD = 0x00
|
||||
KIND_CONSUMER = 0x03
|
||||
KEYBOARD_KINDS = (0x00, 0x01)
|
||||
KINDS = {KIND_KEYBOARD: "keyboard", 0x01: "keyboard", KIND_CONSUMER: "consumer"}
|
||||
|
||||
# Byte 27 when byte 23 is 0x03. Sayo's own numbering, not HID consumer
|
||||
# codes, so every one of these comes from a capture and no others are
|
||||
# guessed. The names match macropad.MEDIA_KEYS so a media binding means
|
||||
# the same thing on either family of pad.
|
||||
CONSUMER = {
|
||||
0x01: "brightnessup",
|
||||
0x02: "brightnessdown",
|
||||
0x0A: "volumeup",
|
||||
0x0B: "volumedown",
|
||||
}
|
||||
CONSUMER_BY_NAME = {v: k for k, v in CONSUMER.items()}
|
||||
|
||||
# Captured records, each the exact 63 bytes sayodevice.com sent, taken from
|
||||
# CAPTURED below so the two can never drift apart. Byte 6 is the key index,
|
||||
# so these cover keys 1, 2 and 8 of an 8 key pad.
|
||||
CAPTURED = [
|
||||
"1256683c00100701000000261b4e0c080708076400000000000000000400000000000000000000000000000015000000000000000000000000000000000000",
|
||||
"1256693c00100701000000261b4e0c080708076400000000000000000500000000000000000000000000000015000000000000000000000000000000000000",
|
||||
"1256773c00100701000000261b4e0c080708076400000000000000001300000000000000000000000000000015000000000000000000000000000000000000",
|
||||
"1258643c00100701000000261b4e0c080708076400000000000000020000000000000000000000000000000015000000000000000000000000000000000000",
|
||||
"125e643c00100701000000261b4e0c080708076400000000000000080000000000000000000000000000000015000000000000000000000000000000000000",
|
||||
"1256643c00100701000000261b4e0c080708076400000000000000000000000000000000000000000000000015000000000000000000000000000000000000",
|
||||
"12c17e3c00100001000000e204e20408070807640000000000000000040000030000000e000000000000000000000000000000000000000100000009500000",
|
||||
"122a863c001001010000004e0ce20408070807640000000000000000040000030000000c0000000000000000000000000000000000000000000000094f0000",
|
||||
]
|
||||
|
||||
# A second capture, of the page starting up, which is where the read
|
||||
# command came from. Request then reply, in the order they happened.
|
||||
STARTUP = [
|
||||
# the directory: which tables this pad has
|
||||
("out", "122512040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"),
|
||||
("in", "1250e11f00000007007b000000004b000004040102030d0e101112161718191a1e1f0000000000000000000000000000000000000000000000000000000000"),
|
||||
# the model name
|
||||
("out", "12261e040c01000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"),
|
||||
("in", "129124340c01005300610079006f00440065007600690063006500200032007800340056002000520047004200000000000000000000000000000000000000"),
|
||||
# the eight keys, asked for one at a time
|
||||
("out", "123512040010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"),
|
||||
("in", "12c2d13c00100001000000e204e20408070807640000000000000001570000030000000e000000000000000000000000000000000000000100000009500000"),
|
||||
("in", "12ad823c001001010000004e0ce20408070807640000008000000003000000030000000c0000000000000000000000000000000000000000000000094f0000"),
|
||||
("in", "1296863c00100201000000ba13e20408070807640000008000000004000000030000000f000000810000000000000000000000014a00000000000000000000"),
|
||||
("in", "126f8f3c00100301000000261be204080708076400000080000000010000000000000000000000000000000000000000000000044b00000000000000000000"),
|
||||
("in", "1214a93c00100401000000e2044e0c08070807640000000000000001560000010000000000000000000000001d000000000000000000000000000000000000"),
|
||||
("in", "1286883c001005010000004e0c4e0c080708076400000001000000072f0000000000000000000000000000001b000000000000000000000000000000000000"),
|
||||
("in", "12f5c83c00100601000000ba134e0c080708076400000000000000073000000000000000000000000000000006000000000000044c00000000000000000000"),
|
||||
("in", "125bb23c00100701000000261b4e0c080708076400000001000000000000000000000000000000000000000015000000000000044e00000000000000000000"),
|
||||
# asking for a ninth key, and being told there isn't one
|
||||
("out", "12351a040010080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"),
|
||||
("in", "12355a044010080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"),
|
||||
# and then the page set key 8 to p, by sending back the record it had
|
||||
# just read with one byte changed. Which is what we do.
|
||||
("out", "125bc53c00100701000000261b4e0c080708076400000001000000001300000000000000000000000000000015000000000000044e00000000000000000000"),
|
||||
]
|
||||
|
||||
LIVE = [bytes.fromhex(h) for d, h in STARTUP if d == "in" and
|
||||
bytes.fromhex(h)[TABLE_AT] == 0x10 and bytes.fromhex(h)[LEN_AT] == 0x3C]
|
||||
|
||||
# A third capture: the page setting key 8 to each of four volume and
|
||||
# brightness keys in turn, with the log cleared first. All four differ
|
||||
# from each other in byte 27 and nothing else, and all four set byte 23
|
||||
# to 0x03 where the key had been 0x01. That pair of facts is what makes
|
||||
# byte 23 the key type rather than drifting state.
|
||||
CONSUMER_CAPTURE = {
|
||||
"volumeup": "1267b23c00100701000000261b4e0c0807080764000000030000000a0000000000000000000000000000000015000000000000044e00000000000000000000",
|
||||
"volumedown": "1268b23c00100701000000261b4e0c0807080764000000030000000b0000000000000000000000000000000015000000000000044e00000000000000000000",
|
||||
"brightnessup": "125eb23c00100701000000261b4e0c080708076400000003000000010000000000000000000000000000000015000000000000044e00000000000000000000",
|
||||
"brightnessdown": "125fb23c00100701000000261b4e0c080708076400000003000000020000000000000000000000000000000015000000000000044e00000000000000000000",
|
||||
}
|
||||
|
||||
|
||||
def _records():
|
||||
out = {}
|
||||
for h in CAPTURED:
|
||||
b = bytes.fromhex(h)
|
||||
out.setdefault(b[KEY_AT], b) # first sighting of each key
|
||||
return out
|
||||
|
||||
|
||||
RECORDS = _records()
|
||||
|
||||
USAGES = {c: 4 + i for i, c in enumerate("abcdefghijklmnopqrstuvwxyz")}
|
||||
USAGES.update({str(d): 30 + i for i, d in enumerate("1234567890")})
|
||||
USAGES.update({
|
||||
"enter": 40, "esc": 41, "backspace": 42, "tab": 43, "space": 44,
|
||||
"minus": 45, "equal": 46, "leftbracket": 47, "rightbracket": 48,
|
||||
"backslash": 49, "semicolon": 51, "quote": 52, "grave": 53,
|
||||
"comma": 54, "period": 55, "slash": 56, "capslock": 57,
|
||||
"printscreen": 70, "insert": 73, "home": 74, "pageup": 75,
|
||||
"delete": 76, "end": 77, "pagedown": 78,
|
||||
"right": 79, "left": 80, "down": 81, "up": 82,
|
||||
"numlock": 83, "numpadslash": 84, "numpadstar": 85,
|
||||
"numpadminus": 86, "numpadplus": 87, "numpadenter": 88, "numpaddot": 99,
|
||||
})
|
||||
for _i in range(1, 13):
|
||||
USAGES[f"f{_i}"] = 57 + _i
|
||||
for _i in range(1, 10):
|
||||
USAGES[f"numpad{_i}"] = 0x58 + _i
|
||||
USAGES["numpad0"] = 0x62
|
||||
|
||||
# Standard HID modifier bits. The pad uses them unchanged.
|
||||
MODIFIERS = {"ctrl": 0x01, "shift": 0x02, "alt": 0x04, "meta": 0x08,
|
||||
"super": 0x08, "win": 0x08,
|
||||
"rctrl": 0x10, "rshift": 0x20, "ralt": 0x40, "rmeta": 0x80}
|
||||
|
||||
|
||||
def parse_combo(text):
|
||||
"""'ctrl+shift+a' -> (0x03, 0x04). A bare modifier gives keycode 0."""
|
||||
mods, usage = 0, 0
|
||||
for part in text.lower().replace(" ", "").split("+"):
|
||||
if not part:
|
||||
continue
|
||||
if part in MODIFIERS:
|
||||
mods |= MODIFIERS[part]
|
||||
elif part in USAGES:
|
||||
usage = USAGES[part]
|
||||
else:
|
||||
raise KeyError(f"don't know {part!r}")
|
||||
return mods, usage
|
||||
|
||||
|
||||
def checksum(payload):
|
||||
"""
|
||||
Sum the whole 64 byte report as little endian 16 bit words with the
|
||||
checksum field zeroed. No magic constant: this reproduces all ten
|
||||
captured packets exactly.
|
||||
"""
|
||||
raw = bytearray([REPORT_ID]) + bytearray(payload)
|
||||
raw[2] = raw[3] = 0 # the checksum's own two bytes
|
||||
if len(raw) % 2:
|
||||
raw.append(0)
|
||||
total = sum(raw[i] | (raw[i + 1] << 8) for i in range(0, len(raw), 2))
|
||||
return total & 0xFFFF
|
||||
|
||||
|
||||
def seal(payload):
|
||||
"""Put the right checksum into a payload and hand it back."""
|
||||
out = bytearray(payload)
|
||||
c = checksum(out)
|
||||
out[CHECK_AT] = c & 0xFF
|
||||
out[CHECK_AT + 1] = (c >> 8) & 0xFF
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def verify(payload):
|
||||
return payload[CHECK_AT] | (payload[CHECK_AT + 1] << 8) == checksum(payload)
|
||||
|
||||
|
||||
def kind_of(record):
|
||||
"""What sort of key this is: 'keyboard', 'consumer', or 'unknown'."""
|
||||
return KINDS.get(record[KIND_AT], "unknown")
|
||||
|
||||
|
||||
def read_binding(record):
|
||||
"""
|
||||
(kind, a, b) from a record, where what a and b mean follows the kind.
|
||||
|
||||
keyboard (modifier bitmask, HID usage)
|
||||
consumer (consumer code, name or None)
|
||||
unknown (byte 23, byte 27) and nothing else to say
|
||||
|
||||
Returning the kind rather than guessing is the whole point: three keys
|
||||
on one real pad are type 0x80, and reading those as a modifier made
|
||||
them look like a bare "ctrl+shift" that nobody had set.
|
||||
"""
|
||||
kind = kind_of(record)
|
||||
if kind == "keyboard":
|
||||
return kind, record[MOD_AT], record[KEYCODE_AT]
|
||||
if kind == "consumer":
|
||||
code = record[MOD_AT]
|
||||
return kind, code, CONSUMER.get(code)
|
||||
return kind, record[KIND_AT], record[MOD_AT]
|
||||
|
||||
|
||||
def patch_binding(record, mods, usage):
|
||||
"""
|
||||
One key's record holding a keyboard shortcut instead, resealed.
|
||||
|
||||
record must be that key's own record, because bytes 11 to 18 are where
|
||||
that key sits on the pad face and other bytes are its own state. Pass
|
||||
another key's record and you move this key on top of that one.
|
||||
|
||||
Byte 23 is set to the keyboard kind. If the key was already a keyboard
|
||||
key its low bit is kept, since that bit shows up either way and the
|
||||
vendor's page leaves it alone when it changes a letter.
|
||||
"""
|
||||
out = bytearray(record)
|
||||
was = out[KIND_AT]
|
||||
out[KIND_AT] = (was & 0x01) if was in KEYBOARD_KINDS else KIND_KEYBOARD
|
||||
out[MOD_AT] = mods
|
||||
out[KEYCODE_AT] = usage
|
||||
return seal(out)
|
||||
|
||||
|
||||
def patch_consumer(record, code):
|
||||
"""
|
||||
One key's record holding a volume or brightness key, resealed.
|
||||
|
||||
code is a key of CONSUMER, or one of its names. Byte 23 becomes 0x03
|
||||
and byte 28 goes to zero, which is exactly what the capture of the
|
||||
page doing this shows.
|
||||
"""
|
||||
if isinstance(code, str):
|
||||
if code not in CONSUMER_BY_NAME:
|
||||
raise KeyError(f"no captured code for {code!r}; known: "
|
||||
f"{', '.join(sorted(CONSUMER_BY_NAME))}")
|
||||
code = CONSUMER_BY_NAME[code]
|
||||
out = bytearray(record)
|
||||
out[KIND_AT] = KIND_CONSUMER
|
||||
out[MOD_AT] = code
|
||||
out[KEYCODE_AT] = 0
|
||||
return seal(out)
|
||||
|
||||
|
||||
def patch_keycode(record, usage):
|
||||
"""A plain key with no modifier."""
|
||||
return patch_binding(record, 0, usage)
|
||||
|
||||
|
||||
def is_heartbeat(payload):
|
||||
"""The status report the pad sends unasked, about once a second."""
|
||||
return bool(payload) and payload[0] == 0x00
|
||||
|
||||
|
||||
def report(payload):
|
||||
"""The 64 bytes to hand to a hidraw write."""
|
||||
return bytes([REPORT_ID]) + bytes(payload)
|
||||
|
||||
|
||||
# ------------------------------------------------------ reading the pad
|
||||
#
|
||||
# A capture of the configurator starting up gave this away. Ask with a
|
||||
# four byte payload naming a table and an index, get the row back. Walk
|
||||
# the index up from zero and the pad tells you when to stop, so the key
|
||||
# count does not have to be guessed or configured anywhere.
|
||||
|
||||
|
||||
def read_request(table, index=0):
|
||||
"""The payload that asks for one row of one table."""
|
||||
out = bytearray(63)
|
||||
out[0] = CMD
|
||||
out[LEN_AT] = REQUEST_LEN
|
||||
out[FLAG_AT] = FLAGS.get(table, 0x00)
|
||||
out[TABLE_AT] = table
|
||||
out[KEY_AT] = index
|
||||
return seal(out)
|
||||
|
||||
|
||||
def parse_reply(payload):
|
||||
"""
|
||||
{table, index, flags, data, end} from anything the pad sends back.
|
||||
|
||||
data is only the meaningful part: byte 3 says how far it runs. end is
|
||||
True when the index does not exist, which is how you find out how many
|
||||
keys a pad has.
|
||||
"""
|
||||
p = bytes(payload)
|
||||
if len(p) < DATA_AT or p[0] != CMD:
|
||||
raise ValueError("not a 0x12 report")
|
||||
size = p[LEN_AT]
|
||||
return {"table": p[TABLE_AT], "index": p[KEY_AT], "flags": p[FLAG_AT],
|
||||
"data": p[FLAG_AT:FLAG_AT + size], "payload": p,
|
||||
"end": bool(p[FLAG_AT] & NO_SUCH_INDEX)}
|
||||
|
||||
|
||||
def is_key_record(payload):
|
||||
"""Is this 63 bytes a key record, rather than a short reply or noise?"""
|
||||
p = bytes(payload)
|
||||
return (len(p) >= DATA_AT and p[0] == CMD and p[TABLE_AT] == TABLE_KEYS
|
||||
and p[LEN_AT] == RECORD_LEN and not p[FLAG_AT] & NO_SUCH_INDEX)
|
||||
|
||||
|
||||
def key_geometry(record):
|
||||
"""
|
||||
(x, y, w, h) of one key on the pad face, from its record.
|
||||
|
||||
The unit is arbitrary but consistent. What it is good for is working
|
||||
out the shape of a pad nobody has told us about: group the keys by y
|
||||
to get the rows.
|
||||
"""
|
||||
u16 = lambda o: record[o] | (record[o + 1] << 8)
|
||||
return u16(11), u16(13), u16(15), u16(17)
|
||||
|
||||
|
||||
def grid_from_records(records):
|
||||
"""
|
||||
(rows, cols) worked out from where the keys actually are.
|
||||
|
||||
Beats guessing from the key count, which cannot tell a 4 by 2 from a
|
||||
2 by 4 or an 8 by 1.
|
||||
"""
|
||||
rows = sorted({key_geometry(r)[1] for r in records})
|
||||
cols = sorted({key_geometry(r)[0] for r in records})
|
||||
return len(rows) or 1, len(cols) or 1
|
||||
|
||||
|
||||
def device_name(payload):
|
||||
"""
|
||||
The model name out of a table 0x01 reply. UTF-16LE, zero padded.
|
||||
|
||||
The declared length can land on an odd byte, which would leave half a
|
||||
character on the end, so trim to even and then cut at the first NUL
|
||||
rather than trusting the padding.
|
||||
"""
|
||||
p = bytes(payload)
|
||||
size = max(0, p[LEN_AT] - 3)
|
||||
raw = p[DATA_AT:DATA_AT + size - (size % 2)]
|
||||
return raw.decode("utf-16-le", "replace").split("\x00")[0].strip()
|
||||
|
||||
|
||||
def tables_offered(payload):
|
||||
"""
|
||||
Which tables a pad admits to having, from a table 0x00 reply.
|
||||
|
||||
Only used to sanity check that 0x10 is there before trusting the rest.
|
||||
"""
|
||||
p = bytes(payload)
|
||||
body = p[FLAG_AT:FLAG_AT + p[LEN_AT]]
|
||||
seen, out = set(), []
|
||||
for b in body[10:]: # the list sits after a header
|
||||
if b and b not in seen:
|
||||
seen.add(b)
|
||||
out.append(b)
|
||||
return out
|
||||
|
||||
|
||||
# ------------------------------------------------------- backup files
|
||||
#
|
||||
# The configurator's "backup" button writes a .sayobak. Inside it, from
|
||||
# offset 102, are the pad's eight key records, 62 bytes each. They are the
|
||||
# same layout as a write payload from byte 11 on, which is the part we
|
||||
# cannot invent, so a backup is a perfectly good substitute for reading the
|
||||
# records off the pad.
|
||||
#
|
||||
# Rebuilding key 1's captured write from its backup record comes out byte
|
||||
# for byte identical, which is what makes this trustworthy.
|
||||
|
||||
BACKUP_MAGIC = b"Sayo Device Backup File"
|
||||
BACKUP_START = 102
|
||||
BACKUP_STRIDE = 62
|
||||
KEYS_IN_BACKUP = 8
|
||||
|
||||
|
||||
def read_backup(data):
|
||||
"""
|
||||
[{key, x, y, w, h, mods, usage, record}] from a .sayobak, one per key.
|
||||
|
||||
x and y are the key's position on the pad face and w and h its size, in
|
||||
whatever unit the firmware uses. On an 8 key pad they come out as a 4 by
|
||||
2 grid with a pitch of 1900 and a size of 1800.
|
||||
"""
|
||||
if hasattr(data, "read_bytes"):
|
||||
data = data.read_bytes()
|
||||
elif isinstance(data, str):
|
||||
data = Path(data).read_bytes()
|
||||
if not data.startswith(BACKUP_MAGIC):
|
||||
raise ValueError("not a Sayo backup file")
|
||||
out = []
|
||||
for i in range(KEYS_IN_BACKUP):
|
||||
off = BACKUP_START + i * BACKUP_STRIDE
|
||||
rec = data[off:off + BACKUP_STRIDE]
|
||||
if len(rec) < BACKUP_STRIDE:
|
||||
break
|
||||
u16 = lambda o: rec[o] | (rec[o + 1] << 8)
|
||||
out.append({"key": i, "x": u16(11), "y": u16(13),
|
||||
"w": u16(15), "h": u16(17),
|
||||
"mods": rec[MOD_AT], "usage": rec[KEYCODE_AT],
|
||||
"record": rec})
|
||||
return out
|
||||
|
||||
|
||||
def payload_from_record(record, key, mods, usage):
|
||||
"""
|
||||
A write payload for one key, built from that key's backup record.
|
||||
|
||||
The header is fixed, bytes 11 onward come from the record, and only the
|
||||
binding changes. Checked against the captures: key 1 comes out byte for
|
||||
byte identical to what the vendor's page sent.
|
||||
"""
|
||||
out = bytearray(63)
|
||||
out[0] = CMD_WRITE_KEY
|
||||
out[3] = 0x3C
|
||||
out[5] = 0x10
|
||||
out[6] = key
|
||||
out[7] = 0x01
|
||||
out[11:62] = record[11:62]
|
||||
return patch_binding(out, mods, usage)
|
||||
|
||||
|
||||
def describe_record(record):
|
||||
"""What one key does, in words, whatever kind of key it is."""
|
||||
kind, a, b = read_binding(record)
|
||||
if kind == "keyboard":
|
||||
return describe(a, b)
|
||||
if kind == "consumer":
|
||||
return b or f"an uncaptured volume or brightness code, 0x{a:02x}"
|
||||
return f"a key type we haven't decoded, 0x{a:02x} with 0x{b:02x}"
|
||||
|
||||
|
||||
def describe(mods, usage):
|
||||
"""'ctrl+shift+a' from a modifier bitmask and a usage code."""
|
||||
names = [n for n, bit in (("ctrl", 0x01), ("shift", 0x02), ("alt", 0x04),
|
||||
("meta", 0x08), ("rctrl", 0x10), ("rshift", 0x20),
|
||||
("ralt", 0x40), ("rmeta", 0x80)) if mods & bit]
|
||||
by_code = {v: k for k, v in sorted(USAGES.items())}
|
||||
if usage:
|
||||
names.append(by_code.get(usage, f"0x{usage:02x}"))
|
||||
return "+".join(names) or "nothing"
|
||||
|
||||
|
||||
# ------------------------------------------------------------- self test
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
bad = 0
|
||||
for h in CAPTURED:
|
||||
b = bytes.fromhex(h)
|
||||
if seal(b) != b or not verify(b):
|
||||
bad += 1
|
||||
print(f" MISMATCH key={b[KEY_AT]} stored={b[1]:02x}{b[2]:02x} "
|
||||
f"computed={seal(b)[1]:02x}{seal(b)[2]:02x}")
|
||||
print(f"{len(CAPTURED) - bad}/{len(CAPTURED)} captured packets reproduced exactly")
|
||||
|
||||
for key, rec in sorted(RECORDS.items()):
|
||||
assert len(rec) == 63, f"key {key} record is {len(rec)} bytes, want 63"
|
||||
assert verify(rec), f"stored record for key {key} fails its own checksum"
|
||||
got = patch_keycode(rec, USAGES["z"])
|
||||
assert verify(got) and got[KEYCODE_AT] == USAGES["z"]
|
||||
print(f"{len(RECORDS)} stored key records verify and re-seal cleanly")
|
||||
|
||||
# the two modifier captures, rebuilt from key 8's record
|
||||
for combo, want in (("shift", "1258643c"), ("meta", "125e643c")):
|
||||
mods, usage = parse_combo(combo)
|
||||
built = patch_binding(RECORDS[7], mods, usage)
|
||||
assert built.hex().startswith(want), f"{combo}: {built[:4].hex()} != {want}"
|
||||
print("left shift and meta captures rebuilt from key 8's record")
|
||||
|
||||
for combo in ("ctrl+a", "ctrl+shift+t", "meta+w", "alt+a"):
|
||||
mods, usage = parse_combo(combo)
|
||||
out = patch_binding(RECORDS[0], mods, usage)
|
||||
assert verify(out)
|
||||
print("modifier combinations build and verify")
|
||||
|
||||
# ------------------------------------------------ the startup capture
|
||||
|
||||
for direction, h in STARTUP:
|
||||
b = bytes.fromhex(h)
|
||||
assert len(b) == 63, f"{h[:8]} is {len(b)} bytes"
|
||||
assert verify(b), f"{h[:8]} fails its own checksum"
|
||||
print(f"{len(STARTUP)} startup packets verify")
|
||||
|
||||
# our read_request() against the three the page actually sent
|
||||
for table, index, want in ((TABLE_DIRECTORY, 0, "122512040000"),
|
||||
(TABLE_NAME, 0, "12261e040c01"),
|
||||
(TABLE_KEYS, 0, "123512040010"),
|
||||
(TABLE_KEYS, 8, "12351a040010")):
|
||||
got = read_request(table, index).hex()
|
||||
assert got.startswith(want), f"table {table:#04x}: {got[:12]} != {want}"
|
||||
print("read requests match the ones the vendor's page sends")
|
||||
|
||||
end = parse_reply(bytes.fromhex(
|
||||
"12355a044010080000000000000000000000000000000000000000000000000000"
|
||||
"0000000000000000000000000000000000000000000000000000000000000000"[:126]))
|
||||
assert end["end"] and end["index"] == 8
|
||||
assert len(LIVE) == 8 and not any(parse_reply(r)["end"] for r in LIVE)
|
||||
print(f"the pad reports {len(LIVE)} keys and says so when asked for a ninth")
|
||||
|
||||
name = device_name(bytes.fromhex(
|
||||
[h for d, h in STARTUP if d == "in" and bytes.fromhex(h)[TABLE_AT] == 1][0]))
|
||||
assert name == "SayoDevice 2x4V RGB", repr(name)
|
||||
print(f"model name reads back as {name!r}")
|
||||
|
||||
assert TABLE_KEYS in tables_offered(bytes.fromhex(STARTUP[1][1]))
|
||||
rows, cols = grid_from_records(LIVE)
|
||||
assert (rows, cols) == (2, 4), (rows, cols)
|
||||
print(f"geometry puts the keys in a {cols} by {rows} grid")
|
||||
|
||||
# the page read key 8 then wrote it. Rebuild that write from that read.
|
||||
rebuilt = payload_from_record(LIVE[7], 7, 0x00, USAGES["p"])
|
||||
assert rebuilt == bytes.fromhex(STARTUP[-1][1]), "read-modify-write diverged"
|
||||
print("the page's own write rebuilt byte for byte from the record it read")
|
||||
|
||||
# ------------------------------------------- volume and brightness
|
||||
|
||||
before = LIVE[7]
|
||||
assert before[KIND_AT] == 0x01, f"key 8 started at {before[KIND_AT]:#04x}"
|
||||
for name, want in CONSUMER_CAPTURE.items():
|
||||
built = patch_consumer(before, name)
|
||||
assert built == bytes.fromhex(want), (
|
||||
f"{name}: {[n for n in range(63) if built[n] != bytes.fromhex(want)[n]]}")
|
||||
print(f"all {len(CONSUMER_CAPTURE)} volume and brightness writes rebuilt exactly")
|
||||
|
||||
kinds = {bytes.fromhex(h)[KIND_AT] for h in CONSUMER_CAPTURE.values()}
|
||||
assert kinds == {KIND_CONSUMER}, kinds
|
||||
print("and each one set byte 23 to 0x03 where the key had been 0x01, "
|
||||
"so byte 23 is the key type")
|
||||
|
||||
for name, h in CONSUMER_CAPTURE.items():
|
||||
kind, code, got = read_binding(bytes.fromhex(h))
|
||||
assert (kind, got) == ("consumer", name), (kind, got, name)
|
||||
print("they read back as the names they were set to")
|
||||
|
||||
# a kind we have never captured must not read as a keyboard shortcut
|
||||
odd = bytearray(LIVE[0])
|
||||
odd[KIND_AT] = 0x80
|
||||
assert kind_of(odd) == "unknown"
|
||||
assert "haven't decoded" in describe_record(odd)
|
||||
print("an uncaptured key type reads as unknown rather than a bare modifier")
|
||||
|
||||
# switching a key back to a shortcut must clear the consumer type
|
||||
back = patch_binding(bytes.fromhex(CONSUMER_CAPTURE["volumeup"]), 0x01,
|
||||
USAGES["c"])
|
||||
assert back[KIND_AT] == KIND_KEYBOARD and verify(back)
|
||||
assert read_binding(back) == ("keyboard", 0x01, USAGES["c"])
|
||||
print("and setting a shortcut on a volume key clears the type back")
|
||||
|
||||
for i, rec in enumerate(LIVE):
|
||||
x, y, w, h = key_geometry(rec)
|
||||
print(f" key {i+1}: at ({x:>5},{y:>5}) size {w}x{h} "
|
||||
f"[{kind_of(rec)}] -> {describe_record(rec)}")
|
||||
|
||||
# a backup, if one is sitting next to this file
|
||||
import glob
|
||||
for path in sorted(glob.glob("*.sayobak")) + sorted(glob.glob("backup.bin")):
|
||||
keys = read_backup(Path(path))
|
||||
print(f"\n{path}: {len(keys)} key records")
|
||||
cols = len({k["x"] for k in keys})
|
||||
print(f" pad is {cols} across by {len(keys)//cols} down")
|
||||
for k in keys:
|
||||
print(f" key {k['key']+1}: at ({k['x']:>5},{k['y']:>5}) "
|
||||
f"size {k['w']}x{k['h']} [{kind_of(k['record'])}] -> "
|
||||
f"{describe_record(k['record'])}")
|
||||
rebuilt = payload_from_record(keys[0]["record"], 0, 0, USAGES["a"])
|
||||
assert verify(rebuilt)
|
||||
assert rebuilt == RECORDS[0], "key 1 should rebuild to its captured write"
|
||||
print(" key 1 rebuilt from the backup matches its captured write exactly")
|
||||
break
|
||||
Reference in New Issue
Block a user