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:
352
tests/test_sayo.py
Normal file
352
tests/test_sayo.py
Normal 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()
|
||||
Reference in New Issue
Block a user