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:
Mohamed Mahjoub
2026-10-08 22:14:31 +01:00
parent 6e25e350e0
commit 6a419eb3b6
34 changed files with 3410 additions and 572 deletions

352
tests/test_sayo.py Normal file
View File

@@ -0,0 +1,352 @@
"""
The SayoDevice backend, replayed against real captures.
No Sayo pad has ever been written to, so these tests stand in for the
hardware: a fake pad answers out of two WebHID captures of the vendor's
own configurator, and the backend has to come out with the same answers
the configurator did. The write test is the important one. It asserts
that our packet is byte for byte the packet the vendor's page sent for
the same change, which is the only check available until somebody runs
it on a desk.
The backend is deliberately not in backends.REGISTRY, so none of this
affects a real pad. Adding it there is one line, once a write has been
confirmed on hardware.
"""
import unittest
from unittest import mock
from macropad_gui import core
from macropad_gui.backends import sayo_proto as wire
from macropad_gui.backends.sayo import Sayo
def _payloads(direction):
return [bytes.fromhex(h) for d, h in wire.STARTUP if d == direction]
class FakePad:
"""
Answers read requests out of the capture and remembers writes.
It replies the way the real pad does: the report id on the front, a
heartbeat first so the backend has to step over one, and nothing at
all for an index that does not exist.
"""
def __init__(self, keys=8, name=True):
self.path = "/dev/hidraw-fake"
self.vid, self.pid, self.report_id = "8089", "000b", wire.REPORT_ID
self.name = "SayoDevice"
self.keys = keys
self.offer_name = name
self.written = []
self.records = {i: r for i, r in enumerate(wire.LIVE)}
# the heartbeat the real pad sends unasked, roughly once a second
HEARTBEAT = bytes([wire.REPORT_ID]) + bytes(63)
def exchange(self, path, request, want=None):
assert path == self.path
assert len(request) == 64 and request[0] == wire.REPORT_ID
asked = wire.parse_reply(request[1:])
table, index = asked["table"], asked["index"]
out = [self.HEARTBEAT]
if table == wire.TABLE_KEYS:
if index < self.keys:
out.append(bytes([wire.REPORT_ID]) + self.records[index])
else:
end = bytearray(wire.read_request(table, index))
end[wire.FLAG_AT] = wire.NO_SUCH_INDEX
out.append(bytes([wire.REPORT_ID]) + wire.seal(end))
elif table == wire.TABLE_NAME and self.offer_name:
out.append(bytes([wire.REPORT_ID]) + _payloads("in")[1])
return out
def write_report(self, path, report):
assert path == self.path
self.written.append(bytes(report))
class Capture(unittest.TestCase):
"""The captures themselves, before any backend touches them."""
def test_every_captured_packet_verifies(self):
for direction, h in wire.STARTUP:
payload = bytes.fromhex(h)
self.assertEqual(len(payload), 63, h[:8])
self.assertTrue(wire.verify(payload), f"{h[:8]} checksum")
def test_the_pad_reported_eight_keys_in_a_four_by_two_grid(self):
self.assertEqual(len(wire.LIVE), 8)
self.assertEqual(wire.grid_from_records(wire.LIVE), (2, 4))
def test_read_requests_match_the_vendors_own(self):
asked_for = 0
for want in _payloads("out"):
if want[wire.LEN_AT] != wire.REQUEST_LEN:
continue # that one is a write
asked = wire.parse_reply(want)
mine = wire.read_request(asked["table"], asked["index"])
self.assertEqual(mine, want, f"table {asked['table']:#04x}")
asked_for += 1
self.assertGreater(asked_for, 2, "the capture should hold several reads")
class Reading(unittest.TestCase):
def setUp(self):
self.pad = FakePad()
self.backend = Sayo()
patch = mock.patch.object(core, "_exchange", self.pad.exchange)
patch.start()
self.addCleanup(patch.stop)
def test_it_discovers_the_key_count_rather_than_assuming_one(self):
self.assertEqual(self.backend.info(self.pad), (8, 0))
def test_a_smaller_pad_reports_itself_smaller(self):
self.pad.keys = 4
self.assertEqual(self.backend.info(self.pad), (4, 0))
def test_a_silent_pad_gives_none_not_a_fake_shape(self):
"""None is what makes the app refuse to write. It has to stay None."""
self.pad.keys = 0
self.assertIsNone(self.backend.info(self.pad))
def test_heartbeats_do_not_get_mistaken_for_answers(self):
self.pad.HEARTBEAT = bytes([wire.REPORT_ID]) + bytes(63)
self.assertEqual(self.backend.info(self.pad), (8, 0))
def test_bindings_read_back_as_the_configurator_shows_them(self):
got = self.backend.read_layer(self.pad)
self.assertEqual(got["key1"].keys, "ctrl+numpadplus")
self.assertEqual(got["key5"].keys, "ctrl+numpadminus")
self.assertEqual(got["key7"].keys, "ctrl+shift+alt+rightbracket")
self.assertTrue(got["key8"].none)
def test_an_uncaptured_key_type_is_left_out_rather_than_guessed(self):
"""
Keys 2, 3 and 4 of the captured pad are type 0x80, which the owner
says are media transport keys. Read as a modifier they came out as
a bare "ctrl+shift" and "alt" that nobody had set, which is worse
than admitting we do not know.
"""
got = self.backend.read_layer(self.pad)
for control in ("key2", "key3", "key4"):
self.assertNotIn(control, got, control)
for control in ("key1", "key5", "key6", "key7", "key8"):
self.assertIn(control, got, control)
def test_it_reads_its_own_model_name(self):
self.assertEqual(self.backend.model(self.pad), "SayoDevice 2x4V RGB")
class Detecting(unittest.TestCase):
def setUp(self):
self.pad = FakePad()
self.backend = Sayo()
patch = mock.patch.object(core, "_exchange", self.pad.exchange)
patch.start()
self.addCleanup(patch.stop)
def test_it_uses_the_pads_geometry_instead_of_guessing(self):
found = self.backend.detect(self.pad)
self.assertTrue(found.speaks)
self.assertEqual((found.keys, found.knobs), (8, 0))
self.assertEqual(found.grid, (2, 4))
self.assertEqual(found.layout.rows, 2)
self.assertEqual(found.layout.cols, 4)
self.assertEqual(found.layout.name, "SayoDevice 2x4V RGB")
def test_a_pad_that_says_nothing_is_left_alone(self):
self.pad.keys = 0
found = self.backend.detect(self.pad)
self.assertFalse(found.speaks)
self.assertIn("key table", found.why)
class Writing(unittest.TestCase):
def setUp(self):
self.pad = FakePad()
self.backend = Sayo()
self.state = mock.Mock()
patch = mock.patch.object(core, "_exchange", self.pad.exchange)
patch.start()
self.addCleanup(patch.stop)
def _write(self, control, binding):
self.backend.write(self.pad, control, binding, self.state,
_write=self.pad.write_report)
def test_our_packet_is_the_one_the_vendors_page_sent(self):
"""
The capture caught the configurator reading key 8 and then setting
it to p. Same starting record, same change, so the bytes have to
match, checksum included.
"""
theirs = _payloads("out")[-1]
self._write("key8", core.Binding(keys="p"))
self.assertEqual(len(self.pad.written), 1)
self.assertEqual(self.pad.written[0], wire.report(theirs))
def test_it_keeps_the_pads_own_bytes_and_changes_only_the_binding(self):
before = wire.LIVE[0]
self._write("key1", core.Binding(keys="ctrl+shift+a"))
after = self.pad.written[0][1:]
moved = [i for i in range(63) if after[i] != before[i]]
self.assertEqual(moved, [wire.CHECK_AT, wire.CHECK_AT + 1,
wire.MOD_AT, wire.KEYCODE_AT])
self.assertEqual(after[wire.MOD_AT], 0x03)
self.assertEqual(after[wire.KEYCODE_AT], 0x04)
def test_clearing_a_key_zeroes_both_binding_bytes(self):
self._write("key1", core.Binding(none=True))
after = self.pad.written[0][1:]
self.assertEqual((after[wire.MOD_AT], after[wire.KEYCODE_AT]), (0, 0))
def test_a_key_the_pad_does_not_have_is_refused_before_sending(self):
with self.assertRaises(core.WriteError) as caught:
self._write("key9", core.Binding(keys="a"))
self.assertFalse(caught.exception.pad_touched)
self.assertEqual(self.pad.written, [])
def test_the_four_captured_media_keys_write(self):
"""
Each one has to come out as the exact packet the vendor's page sent
for the same change, which is what the third capture recorded.
"""
for name, want in wire.CONSUMER_CAPTURE.items():
self.pad.written.clear()
self._write("key8", core.Binding(media=name))
self.assertEqual(self.pad.written[0], wire.report(bytes.fromhex(want)),
name)
def test_a_media_key_we_have_not_captured_is_refused(self):
"""
This pad numbers media keys its own way, not by HID code, so a name
we have never seen set cannot be derived. Saying so beats writing a
number we made up.
"""
with self.assertRaises(core.WriteError) as caught:
self._write("key8", core.Binding(media="playpause"))
self.assertIn("captured", str(caught.exception).lower())
self.assertFalse(caught.exception.pad_touched)
self.assertEqual(self.pad.written, [])
def test_an_uncaptured_key_type_is_never_overwritten(self):
"""
The real risk this guards: writing a shortcut onto a type 0x80 key
would leave it flagged as one kind of key holding another kind's
bytes. Nothing reaches the pad, so the old binding survives.
"""
with self.assertRaises(core.WriteError) as caught:
self._write("key2", core.Binding(keys="a"))
self.assertFalse(caught.exception.pad_touched)
self.assertEqual(self.pad.written, [])
self.assertIn("0x80", str(caught.exception))
def test_switching_a_media_key_to_a_shortcut_clears_the_type(self):
self.pad.records[7] = bytes.fromhex(wire.CONSUMER_CAPTURE["volumeup"])
self._write("key8", core.Binding(keys="ctrl+c"))
after = self.pad.written[0][1:]
self.assertEqual(after[wire.KIND_AT], wire.KIND_KEYBOARD)
self.assertEqual(wire.read_binding(after), ("keyboard", 0x01, 0x06))
def test_a_keyboard_write_keeps_the_low_bit_of_the_type(self):
"""
Key 8 was type 0x01 and the vendor's page set it to p without
touching that bit, so neither do we.
"""
self._write("key8", core.Binding(keys="p"))
self.assertEqual(self.pad.written[0][1:][wire.KIND_AT], 0x01)
def test_a_sequence_is_refused_because_the_pad_holds_one_keystroke(self):
with self.assertRaises(core.WriteError) as caught:
self._write("key1", core.Binding(keys="a,b,c"))
self.assertFalse(caught.exception.pad_touched)
def test_knobs_are_refused_since_this_pad_has_none(self):
with self.assertRaises(ValueError):
self.backend.action_byte("dial1left")
def test_everything_written_reseals_and_round_trips(self):
for combo in ("a", "ctrl+c", "ctrl+shift+t", "meta+l", "super+l",
"win+l", "f5", "space", "numpadminus"):
self.pad.written.clear()
self._write("key1", core.Binding(keys=combo))
payload = self.pad.written[0][1:]
self.assertTrue(wire.verify(payload), combo)
mods, code = wire.parse_combo(combo)
self.assertEqual(payload[wire.MOD_AT], mods, combo)
self.assertEqual(payload[wire.KEYCODE_AT], code, combo)
class Drawing(unittest.TestCase):
"""
Every pad before this one had knobs, so a knobless board is a shape the
drawing code has never been given. It must come out in every theme
rather than dividing by a knob count of zero somewhere.
"""
def test_a_knobless_pad_draws_in_every_theme(self):
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication
from PySide6.QtGui import QPixmap
from macropad_gui import padview, themes
QApplication.instance() or QApplication([])
shape = core.Layout(8, 0, 2, 4, "SayoDevice 2x4V RGB")
for name, theme in sorted(themes.THEMES.items()):
padview.apply_theme(theme)
view = padview.PadView()
view.layout = shape
view.looks = {c: padview.Look() for c in shape.control_ids}
view.resize(900, 520)
shot = QPixmap(view.size())
view.render(shot)
self.assertFalse(shot.isNull(), name)
def test_a_knobless_layout_has_only_keys(self):
shape = core.Layout(8, 0, 2, 4)
self.assertEqual(len(shape.control_ids), 8)
self.assertFalse([c for c in shape.control_ids if c.startswith("dial")])
class Routing(unittest.TestCase):
"""
The two families must not reach into each other. A Sayo pad has to go
to the Sayo backend and a CH57x to the CH57x one, and a Sayo pid we
have never seen must not be claimed outright: it goes through detect()
first, the same as an unknown CH57x sibling.
"""
def test_a_sayo_pad_routes_to_the_sayo_backend(self):
from macropad_gui import backends
self.assertEqual(backends.for_vid_pid("8089", "000b").key, "sayo")
def test_a_ch57x_pad_still_routes_to_ch57x(self):
from macropad_gui import backends
self.assertEqual(backends.for_vid_pid("1189", "8840").key, "ch57x")
def test_the_ch57x_backend_does_not_claim_a_sayo(self):
from macropad_gui import backends
self.assertFalse(backends.DEFAULT.claims("8089", "000b"))
def test_an_unseen_sayo_pid_is_not_claimed_outright(self):
from macropad_gui import backends
self.assertIsNone(backends.for_vid_pid("8089", "0099"))
self.assertTrue(Sayo().could_claim("8089", "0099"))
def test_the_installer_offers_a_rule_for_the_sayo_vendor(self):
from macropad_gui import core
self.assertTrue(any("8089" in line for line in core.rule_lines()))
if __name__ == "__main__":
unittest.main()