Add files via upload

This commit is contained in:
Mohamed Mahjoub
2026-09-11 20:30:34 +01:00
committed by GitHub
parent 07f3af3441
commit c7c02edb60
35 changed files with 3975 additions and 13 deletions

0
tests/__init__.py Normal file
View File

View File

@@ -0,0 +1,27 @@
# capture of Bus 001 Device 011: ID 1189:8840 Acer Communications & Multimedia USB Composite Device
# bus 1 device 11
[1] interrupt out ep=4
03fd100101000000000001000500000000000000000000000000000000000000
[2] interrupt out ep=4
03fdfeff00000000000000000000000000000000000000000000000000000000
[3] interrupt out ep=4
03fd110101000000000001000500000000000000000000000000000000000000
[4] interrupt out ep=4
03fdfeff00000000000000000000000000000000000000000000000000000000
[5] interrupt out ep=4
03fd120101000000000001000600000000000000000000000000000000000000
[6] interrupt out ep=4
03fdfeff00000000000000000000000000000000000000000000000000000000
[7] interrupt out ep=4
03fd130101000000000001002300000000000000000000000000000000000000
[8] interrupt out ep=4
03fdfeff00000000000000000000000000000000000000000000000000000000
[9] interrupt out ep=4
03fd140101000000000001002400000000000000000000000000000000000000
[10] interrupt out ep=4
03fdfeff00000000000000000000000000000000000000000000000000000000
[11] interrupt out ep=4
03fd150101000000000001002500000000000000000000000000000000000000
[12] interrupt out ep=4
03fdfeff00000000000000000000000000000000000000000000000000000000

View File

@@ -0,0 +1,39 @@
# capture of Bus 001 Device 011: ID 1189:8840 Acer Communications & Multimedia USB Composite Device
# keys 1-12 set to media keys in the vendor app, saved once (one commit),
# then dial 1 set to 1 / 2 / 3 on the slots labelled anticlockwise / press / clockwise
# bus 1 device 11
[1] interrupt out ep=4
03fd010102000000000002b50000000000000000000000000000000000000000
[2] interrupt out ep=4
03fd020102000000000002b70000000000000000000000000000000000000000
[3] interrupt out ep=4
03fd030102000000000002b60000000000000000000000000000000000000000
[4] interrupt out ep=4
03fd040102000000000002cd0000000000000000000000000000000000000000
[5] interrupt out ep=4
03fd050102000000000002ea0000000000000000000000000000000000000000
[6] interrupt out ep=4
03fd060102000000000002e90000000000000000000000000000000000000000
[7] interrupt out ep=4
03fd070102000000000002e20000000000000000000000000000000000000000
[8] interrupt out ep=4
03fd080102000000000002920100000000000000000000000000000000000000
[9] interrupt out ep=4
03fd0901020000000000028a0100000000000000000000000000000000000000
[10] interrupt out ep=4
03fd0a0102000000000002940100000000000000000000000000000000000000
[11] interrupt out ep=4
03fd0b0102000000000002700000000000000000000000000000000000000000
[12] interrupt out ep=4
03fd0c01020000000000026f0000000000000000000000000000000000000000
[13] interrupt out ep=4
03fdfeff00000000000000000000000000000000000000000000000000000000
[14] interrupt out ep=4
03fd100101000000000001001e00000000000000000000000000000000000000
[15] interrupt out ep=4
03fd110101000000000001001f00000000000000000000000000000000000000
[16] interrupt out ep=4
03fd120101000000000001002000000000000000000000000000000000000000
[17] interrupt out ep=4
03fdfeff00000000000000000000000000000000000000000000000000000000

297
tests/test_core.py Normal file
View File

@@ -0,0 +1,297 @@
"""
Run from the repo root: python3 -m unittest -v
The first test class is the important one. It replays the packets the
vendor's own software sent (captured over usbmon) and checks that the
path the GUI uses - Binding -> macropad.py - produces the same bytes.
If anyone edits the protocol layer and breaks it, this fails.
"""
import errno
import json
import os
import re
import tempfile
import unittest
from pathlib import Path
from macropad_gui import core
import macropad as proto
CAPTURES = Path(__file__).parent / "captures"
def load_capture(path):
"""[(label, bytes)] from a macropad-capture.py output file."""
out, label = [], None
for line in path.read_text().splitlines():
m = re.match(r"\[(\d+)\] (.*)", line)
if m:
label = m.group(0)
elif line.startswith(" ") and label:
out.append((label, bytes.fromhex(line.strip())))
return out
KEYNAME = {}
for _name, _code in proto.KEYCODES.items():
KEYNAME.setdefault(_code, _name)
class CaptureReplay(unittest.TestCase):
def test_dial_capture_reproduced_exactly(self):
packets = load_capture(CAPTURES / "1189-8840-dials.txt")
self.assertEqual(len(packets), 12)
for i in range(0, len(packets), 2):
(_, config), (_, commit) = packets[i], packets[i + 1]
# usbmon's text interface stops at 32 bytes; the rest is unseen.
self.assertEqual(len(config), 32)
action, layer, mods, code = config[2], config[3], config[11], config[12]
self.assertEqual(mods, 0)
binding = core.Binding(keys=KEYNAME[code])
ours = binding.reports(3, action, layer)
self.assertEqual(len(ours), 2, "config + commit")
self.assertEqual(ours[0][:32], config, f"config for action 0x{action:02x}")
self.assertEqual(ours[1][:32], commit, "commit")
self.assertEqual(len(ours[0]), 65)
self.assertFalse(any(ours[0][32:]), "bytes past the capture are zero")
def test_dial_action_order_matches_capture(self):
packets = load_capture(CAPTURES / "1189-8840-dials.txt")
captured = [p[2] for _, p in packets[0::2]]
ours = [core.action_byte(c) for c in core.CONTROL_IDS if c.startswith("dial")]
self.assertEqual(ours, captured)
def test_keys_are_1_to_12(self):
self.assertEqual([core.action_byte(k) for k in core.KEYS], list(range(1, 13)))
class EveryCapture(unittest.TestCase):
"""Every config packet in every capture must decode and re-encode exactly."""
def test_round_trip_all_captures(self):
seen = 0
for path in sorted(CAPTURES.glob("*.txt")):
for report in core.read_capture(path):
if core.is_commit(report):
self.assertEqual(proto.native_commit(3)[:len(report)], report)
continue
decoded = core.decode_config(report)
self.assertIsNotNone(decoded, f"{path.name}: {report.hex()}")
control, layer, binding = decoded
ours = binding.reports(3, core.action_byte(control), layer)[0]
self.assertEqual(ours[:len(report)], report,
f"{path.name} {control}: ours {ours[:16].hex()}")
seen += 1
self.assertGreaterEqual(seen, 21)
def test_media_keys_capture(self):
"""Vendor app: keys 1-12 set to media keys, saved with one commit."""
want = ["next", "stop", "prev", "playpause", "volumedown", "volumeup",
"mute", "calculator", "mail", "mycomputer",
"brightnessdown", "brightnessup"]
with tempfile.TemporaryDirectory() as d:
state = core.State(Path(d) / "s.json")
got = core.import_capture(CAPTURES / "1189-8840-media-keys.txt", state)
self.assertEqual([c for c, _, _ in got][:12], list(core.KEYS))
self.assertEqual([b.media for _, _, b in got][:12], want)
# dial 1, slots labelled anticlockwise / press / clockwise -> 1 / 2 / 3
self.assertEqual(state.get("dial1-left").binding, core.Binding(keys="1"))
self.assertEqual(state.get("dial1-push").binding, core.Binding(keys="2"))
self.assertEqual(state.get("dial1-right").binding, core.Binding(keys="3"))
self.assertTrue(state.get("dial2-left").unknown)
def test_uncommitted_configs_are_not_imported(self):
with tempfile.TemporaryDirectory() as d:
cap = Path(d) / "c.txt"
cap.write_text("[1] interrupt out ep=4\n"
" 03fd010101000000000001000400\n")
state = core.State(Path(d) / "s.json")
self.assertEqual(core.import_capture(cap, state), [])
self.assertTrue(state.get("key1").unknown)
class BindingModel(unittest.TestCase):
def test_round_trip(self):
for b in (core.Binding(keys="ctrl+shift+n"),
core.Binding(keys="a,b,c", delay=250),
core.Binding(media="volumeup")):
self.assertEqual(core.Binding.from_json(json.loads(json.dumps(b.to_json()))), b)
def test_rejects_nonsense(self):
bad = [core.Binding(), core.Binding(keys="a", media="mute"),
core.Binding(media="nope"), core.Binding(keys="ctrl+wat"),
core.Binding(media="mute", delay=10), core.Binding(keys="a", delay=-1)]
for b in bad:
with self.assertRaises(ValueError, msg=repr(b)):
b.validate()
def test_normalize(self):
self.assertEqual(core.normalize_keys(" Ctrl + C ,"), "ctrl+c")
self.assertEqual(core.normalize_keys("a, b"), "a,b")
self.assertEqual(core.normalize_keys("Meta+Ctrl+Left"), "super+ctrl+left")
def test_labels(self):
self.assertEqual(core.Binding(keys="ctrl+shift+n").label(), "Ctrl+Shift+N")
self.assertEqual(core.Binding(keys="super+pageup").label(), "Meta+Page Up")
self.assertEqual(core.Binding(media="mycomputer").label(), "My computer")
def test_pause_is_unambiguous(self):
key = core.Binding(keys="pause").reports(3, 1)[0]
media = core.Binding(media="pause").reports(3, 1)[0]
self.assertEqual(key[4], proto.KeyType.BASIC)
self.assertEqual(media[4], proto.KeyType.MULTIMEDIA)
class ShadowState(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.path = Path(self.tmp.name) / "state.json"
self.dev = core.Device(path="/dev/null-pad", report_id=3, writable=True)
def tearDown(self):
self.tmp.cleanup()
def test_everything_starts_unknown(self):
s = core.State(self.path)
self.assertTrue(all(s.get(c).unknown for c in core.CONTROL_IDS))
def test_successful_write_is_recorded_and_persisted(self):
sent = []
s = core.State(self.path)
b = core.Binding(keys="ctrl+c")
core.write_binding(self.dev, "key4", b, s, _write=lambda p, r: sent.append(r))
self.assertEqual([r[:4] for r in sent], [b"\x03\xfd\x04\x01", b"\x03\xfd\xfe\xff"])
again = core.State(self.path)
self.assertFalse(again.get("key4").unknown)
self.assertEqual(again.get("key4").binding, b)
self.assertEqual([f for f in os.listdir(self.tmp.name) if f.endswith(".tmp")], [])
def test_failed_commit_leaves_control_unknown(self):
s = core.State(self.path)
s.record("key1", core.Binding(keys="a"))
calls = []
def flaky(path, report):
calls.append(report)
if len(calls) == 2:
raise OSError(errno.EIO, "I/O error")
with self.assertRaises(core.WriteError) as ctx:
core.write_binding(self.dev, "key1", core.Binding(keys="b"), s, _write=flaky)
self.assertTrue(ctx.exception.pad_touched)
e = core.State(self.path).get("key1")
self.assertTrue(e.unknown)
self.assertIn("1 of 2", e.reason)
def test_unplugged_before_first_write_changes_nothing(self):
s = core.State(self.path)
s.record("key1", core.Binding(keys="a"))
s.save()
def gone(path, report):
raise FileNotFoundError(errno.ENOENT, "No such file")
with self.assertRaises(core.WriteError) as ctx:
core.write_binding(self.dev, "key1", core.Binding(keys="b"), s, _write=gone)
self.assertFalse(ctx.exception.pad_touched)
self.assertEqual(core.State(self.path).get("key1").binding, core.Binding(keys="a"))
class UdevOrdering(unittest.TestCase):
"""udev sorts rule files by name; the uaccess tag must land before the cutoff."""
def test_ordering(self):
cutoff = "73-seat-late.rules"
self.assertLess("60-macropad.rules", cutoff)
self.assertLess("70-macropad.rules", cutoff)
self.assertGreaterEqual("99-macropad.rules", cutoff)
self.assertGreaterEqual("macropad.rules", cutoff) # letters sort after digits
def test_diagnose_without_hardware_does_not_crash(self):
d = core.diagnose()
self.assertIn(d.status, {"ready", "unplugged", "no-interface", "no-permission"})
if __name__ == "__main__":
unittest.main()
class PermissionDiagnosis(unittest.TestCase):
"""The three ways a pad ends up unwritable, with the system faked out."""
def diag(self, rules, tags):
from unittest import mock
dev = [{"path": "/dev/hidraw10", "pid": "8840", "report_id": 3,
"writable": False, "name": "USB Composite Device"}]
with mock.patch.object(proto, "find_devices", return_value=dev), \
mock.patch.object(core, "_matching_rules", return_value=rules), \
mock.patch.object(core, "_udev_tags", return_value=tags), \
mock.patch.object(core, "_uaccess_cutoff", return_value="73-seat-late.rules"), \
mock.patch.object(core, "_keyd_warning", return_value=None):
return core.diagnose()
def test_rule_numbered_too_late(self):
d = self.diag(["/etc/udev/rules.d/99-macropad.rules"], {"uaccess", "seat"})
self.assertEqual(d.status, "no-permission")
self.assertIn("sudo mv /etc/udev/rules.d/99-macropad.rules "
"/etc/udev/rules.d/60-macropad.rules", d.fix)
def test_rule_not_loaded_yet(self):
d = self.diag(["/etc/udev/rules.d/60-macropad.rules"], set())
self.assertTrue(any("reload-rules" in c for c in d.fix))
self.assertFalse(any(c.startswith("sudo mv") for c in d.fix))
def test_no_rule(self):
d = self.diag([], set())
self.assertTrue(d.fix[0].startswith("echo 'KERNEL==\"hidraw*\""))
self.assertIn("60-macropad.rules", d.fix[0])
def test_other_family_pad_is_refused_not_ignored(self):
from unittest import mock
other = [{"path": "/dev/hidraw4", "vid": "1189", "pid": "8890",
"report_id": 0, "writable": True, "name": ""}]
with mock.patch.object(proto, "find_devices", return_value=other), \
mock.patch.object(core, "_keyd_warning", return_value=None):
d = core.diagnose()
self.assertEqual(d.status, "unsupported")
self.assertIn("1189:8890", d.headline)
self.assertIsNone(d.device)
def test_rule_fine_but_not_local_session(self):
d = self.diag(["/etc/udev/rules.d/60-macropad.rules"], {"uaccess"})
self.assertEqual(d.fix, [])
self.assertIn("SSH", d.detail[0])
class GuiSmoke(unittest.TestCase):
"""Builds the window offscreen. Skipped if Qt for Python isn't installed."""
def test_window_builds_and_selects(self):
try:
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication
from macropad_gui import app
except ImportError:
self.skipTest("PySide6 not installed")
from unittest import mock
with tempfile.TemporaryDirectory() as d, \
mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": d}):
qa = QApplication.instance() or QApplication([])
w = app.Window()
w.timer.stop()
for c in core.CONTROL_IDS:
w.pad.select(c)
w._refresh()
self.assertEqual(w.inspector.title.text(), core.control_name(c))
w._changed("key1", core.Binding(media="mute"))
self.assertEqual(w._pending(), ["key1"])
w.desired.clear()
w.close()