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

195
tests/test_backends.py Normal file
View File

@@ -0,0 +1,195 @@
"""
The backend seam.
A split you cannot exercise is not a split, so these tests register a second,
imaginary family and check that the app routes to it: different vendor id,
different slot numbering, different everything. Nothing here touches real
hardware and the fake backend is removed again afterwards.
"""
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from macropad_gui import backends, core
from macropad_gui.backends.base import Backend
import macropad as proto
class Imaginary(Backend):
"""
Stands in for a second device family. Eight keys, no knobs, slots
numbered from 0x20 so nothing it returns could be mistaken for a CH57x
answer that leaked through.
"""
key = "imaginary"
name = "Imaginary pad"
vid = "c0de"
pids = ("0001",)
layers = 1
max_keys = 8
max_knobs = 0
def __init__(self):
self.calls = []
def action_byte(self, control):
self.calls.append(("action_byte", control))
return 0x20 + int(control[3:]) - 1
def info(self, device):
self.calls.append(("info", device.path))
return 8, 0
def read_layer(self, device, layer=1, layout=None):
self.calls.append(("read_layer", layer))
return {f"key{i}": core.Binding(keys="a") for i in range(1, 9)}
def write(self, device, control, binding, state, layer=1, _write=None):
self.calls.append(("write", control))
state.record(control, binding, layer)
state.save()
def detect(self, device):
self.calls.append(("detect", device.path))
return core.Detection(speaks=True, keys=8, knobs=0, layers=1,
bindings={"key1": core.Binding(keys="a")})
def fake_device(vid="c0de", pid="0001", path="/dev/null-pad"):
return core.Device(path=path, report_id=3, writable=True, vid=vid, pid=pid)
class Registry(unittest.TestCase):
def setUp(self):
self.fake = Imaginary()
self._saved = backends.REGISTRY
backends.REGISTRY = backends.REGISTRY + (self.fake,)
def tearDown(self):
backends.REGISTRY = self._saved
def test_each_family_gets_its_own_device(self):
self.assertIs(backends.for_vid_pid("c0de", "0001"), self.fake)
self.assertIsInstance(backends.for_vid_pid("1189", "8840"),
backends.CH57x)
def test_an_unknown_id_is_claimed_by_nobody(self):
self.assertIsNone(backends.for_vid_pid("dead", "beef"))
def test_a_sibling_under_a_known_vendor_is_not_claimed(self):
"""
1189:8890 exists and is not ours. It must not be claimed on the
strength of its vendor id, or the app would write to a pad whose
protocol it has never confirmed.
"""
self.assertIsNone(backends.for_vid_pid("1189", "8890"))
ch = backends.CH57x()
self.assertTrue(ch.could_claim("1189", "8890")) # still worth finding
def test_for_device_falls_back_rather_than_returning_none(self):
self.assertIsInstance(backends.for_device(None), backends.CH57x)
self.assertIsInstance(backends.for_device(fake_device("1189", "8890")),
backends.CH57x)
class Routing(unittest.TestCase):
"""core's functions must land on the backend that owns the device."""
def setUp(self):
self.fake = Imaginary()
self._saved = backends.REGISTRY
backends.REGISTRY = backends.REGISTRY + (self.fake,)
self.dir = tempfile.TemporaryDirectory()
self.state = core.State(Path(self.dir.name) / "s.json")
def tearDown(self):
backends.REGISTRY = self._saved
self.dir.cleanup()
def test_detect_routes(self):
found = core.detect(fake_device())
self.assertTrue(found.speaks)
self.assertEqual((found.keys, found.knobs), (8, 0))
self.assertIn(("detect", "/dev/null-pad"), self.fake.calls)
def test_read_routes(self):
got = core.read_layer(fake_device())
self.assertEqual(len(got), 8)
self.assertIn(("read_layer", 1), self.fake.calls)
def test_info_routes(self):
self.assertEqual(core.read_info(fake_device()), (8, 0))
def test_write_routes(self):
core.write_binding(fake_device(), "key1", core.Binding(keys="a"),
self.state)
self.assertIn(("write", "key1"), self.fake.calls)
self.assertEqual(self.state.get("key1").binding, core.Binding(keys="a"))
def test_slot_numbering_is_per_family(self):
self.assertEqual(self.fake.action_byte("key1"), 0x20)
self.assertEqual(core.action_byte("key1"), 1) # still CH57x
self.assertEqual(core.action_byte("key1", self.fake), 0x20)
def test_the_real_backend_is_untouched_by_the_fake(self):
ch = backends.for_vid_pid("1189", "8840")
self.assertEqual(ch.action_byte("dial2-right"), 0x15)
self.assertEqual(ch.max_keys, proto.KEY_SLOTS)
def test_discovery_covers_every_vendor(self):
self.assertEqual(core.vendors(), {"1189", "8089", "c0de"})
with mock.patch.object(proto, "find_devices", return_value=[]) as f:
core.discover()
f.assert_called_once()
self.assertEqual(f.call_args[0][0], {"1189", "8089", "c0de"})
def test_one_udev_rule_per_vendor(self):
lines = core.rule_lines()
self.assertEqual(len(lines), len(backends.REGISTRY))
self.assertTrue(any('"1189"' in l for l in lines))
self.assertTrue(any('"c0de"' in l for l in lines))
for line in lines:
self.assertIn('TAG+="uaccess"', line)
self.assertIn('KERNEL=="hidraw*"', line)
class HalfWritten(unittest.TestCase):
"""A backend that forgets a method should fail loudly, not silently."""
def test_the_base_class_refuses_to_guess(self):
bare = Backend()
dev = fake_device()
for call in (lambda: bare.action_byte("key1"),
lambda: bare.info(dev),
lambda: bare.read_layer(dev),
lambda: bare.write(dev, "key1", None, None),
lambda: bare.detect(dev)):
with self.assertRaises(NotImplementedError):
call()
class StateFiles(unittest.TestCase):
def test_the_default_file_name_has_not_changed(self):
"""Renaming it would orphan every existing user's bindings."""
with tempfile.TemporaryDirectory() as d:
with mock.patch.object(core, "config_dir", return_value=Path(d)):
self.assertEqual(core.State().path.name, "state-1189-8840.json")
def test_a_second_family_gets_its_own_file(self):
with tempfile.TemporaryDirectory() as d:
with mock.patch.object(core, "config_dir", return_value=Path(d)):
s = core.State(device_id="c0de:0001")
self.assertEqual(s.path.name, "state-c0de-0001.json")
s.record("key1", core.Binding(keys="a"))
s.save()
import json
self.assertEqual(json.loads(s.path.read_text())["device"],
"c0de:0001")
if __name__ == "__main__":
unittest.main()