From 6e25e350e089b7450f1a8c7462ae3972f84ff285 Mon Sep 17 00:00:00 2001 From: Mohamed Mahjoub <75433420+Probler-Yt@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:01:48 +0100 Subject: [PATCH] Added wider support for more boards Addedd support for 5x3 key layouts aswell as 3 knobs. aswell as fixed a bug in the capturing script that would omit anything past 12 keys / 2 knobs showing up in the report. --- macropad.py | 13 +++ macropad_gui/app.py | 63 +++++++++++++- macropad_gui/core.py | 55 +++++++++--- macropad_gui/padview.py | 39 +++++++-- tests/test_core.py | 183 +++++++++++++++++++++++++++++++++++++++- 5 files changed, 327 insertions(+), 26 deletions(-) diff --git a/macropad.py b/macropad.py index 130dd22..2a6106e 100644 --- a/macropad.py +++ b/macropad.py @@ -65,6 +65,19 @@ for _i in range(1, 13): for _i in range(13, 25): KEYCODES[f"f{_i}"] = 0x68 + _i - 13 # F13 = 0x68 .. F24 = 0x73 +# The keypad block, HID usages 0x53 to 0x63. Pads in this family ship from +# the factory with keypad bindings on the slots the vendor software doesn't +# show, so without these a first read of a brand new pad reports controls it +# can't name. +KEYCODES.update({ + "numlock": 0x53, "numpadslash": 0x54, "numpadstar": 0x55, + "numpadminus": 0x56, "numpadplus": 0x57, "numpadenter": 0x58, + "numpaddot": 0x63, +}) +for _i in range(1, 10): + KEYCODES[f"numpad{_i}"] = 0x58 + _i # numpad1 = 0x59 .. numpad9 = 0x61 +KEYCODES["numpad0"] = 0x62 + # Media keys live on the Consumer usage page, sent as a 16-bit value. MEDIA_KEYS = { "play": 0x00CD, "pause": 0x00CD, "playpause": 0x00CD, diff --git a/macropad_gui/app.py b/macropad_gui/app.py index f35ea59..0afcb1a 100644 --- a/macropad_gui/app.py +++ b/macropad_gui/app.py @@ -372,7 +372,7 @@ class Inspector(QWidget): # -------------------------------------------------------------- banner - def show_problem(self, diag, note=None): + def show_problem(self, diag, note=None, offer_detect=False): if diag is None or diag.ready: if not note: self.banner.hide() @@ -390,7 +390,7 @@ class Inspector(QWidget): self.banner_fix.setFixedHeight(lh * max(1, len(cmds)) + 30) self.banner_fix.setVisible(bool(cmds)) self.banner_copy.setVisible(bool(cmds)) - self.banner_action.setVisible(diag.status == "unsupported") + self.banner_action.setVisible(diag.status == "unsupported" or offer_detect) self.banner_action.setText("Ask the pad what it is") if diag.fix and diag.fix[-1].startswith("#"): self.banner_text.setText(self.banner_text.text() + "\n\nThen unplug the pad and plug it back in.") @@ -750,6 +750,12 @@ class Window(QMainWindow): # Pads the user has identified by asking them. Remembered so the app # doesn't go back to calling them unsupported on the next check. self.adopted = set(self.settings.value("adopted", []) or []) + # What the pad says its shape is, asked once per pad. A USB id does + # not pin down a layout: 1189:8840 covers the 12 key pad this was + # built on and a 15 key, 3 knob sibling. + self.reported = None # (keys, knobs) or None + self.shape_checked = set() + self.shape_note = "" root = QWidget() outer = QVBoxLayout(root) @@ -786,6 +792,12 @@ class Window(QMainWindow): box.valueChanged.connect(self._resize_layout) self.spin[name] = box eb.addWidget(box) + eb.addSpacing(12) + self.knob_side_btn = _button("Knobs: right", "flat") + self.knob_side_btn.setToolTip( + "Which side of the keys your knobs are on") + self.knob_side_btn.clicked.connect(self._flip_knob_side) + eb.addWidget(self.knob_side_btn) eb.addStretch() self.edit_done = _button("Done", "primary") self.edit_cancel = _button("Cancel", "flat") @@ -1057,7 +1069,9 @@ class Window(QMainWindow): "unsupported": "Unsupported pad", "unsupported-os": "Not supported on this system", }[self.diag.status]) - self.inspector.show_problem(self.diag, self.note) + self._check_shape() + self.inspector.show_problem(self.diag, self.note or self.shape_note, + offer_detect=bool(self.shape_note)) self.inspector.show_background(bool(act.listener_pid())) if self.diag.status != old: self._refresh(editor=False) @@ -1156,6 +1170,39 @@ class Window(QMainWindow): self.read_btn.setText("Read pad") self._refresh() + def _check_shape(self): + """ + Ask a working pad how many keys and knobs it has and say something + if that disagrees with the drawing. + + The query runs once per pad; the comparison runs every poll, so + fixing the layout by hand clears the note straight away. + """ + dev = self.diag.device if self.diag else None + if not self.diag or not self.diag.ready or not dev: + self.shape_note = "" + return + tag = f"{dev.vid}:{dev.pid}@{dev.path}" + if tag not in self.shape_checked: + self.shape_checked.add(tag) + try: + self.reported = core.read_info(dev) + except Exception: # a pad that won't answer is fine + self.reported = None + if not self.reported: + self.shape_note = "" + return + keys, knobs = self.reported + lay = self.state.layout + if (keys, knobs) == (lay.keys, lay.knobs): + self.shape_note = "" + return + self.shape_note = ( + f"This pad says it has {keys} keys and {knobs} " + f"knob{'s' if knobs != 1 else ''}, but it is drawn with " + f"{lay.keys} and {lay.knobs}. Ask it what it is and the drawing " + f"will match.") + def _identify(self): """ Interrogate a pad we don't recognise. Reads only. If it answers @@ -1188,6 +1235,8 @@ class Window(QMainWindow): self.desired.clear() self.adopted.add(f"{dev.vid}:{dev.pid}") self.settings.setValue("adopted", sorted(self.adopted)) + self.reported = (layout.keys, layout.knobs) + self.shape_note = "" self._poll() self._say(f"It says {layout.keys} keys and {layout.knobs} " f"knob{'s' if layout.knobs != 1 else ''}. Check the drawing " @@ -1210,6 +1259,8 @@ class Window(QMainWindow): self.spin[name].blockSignals(True) self.spin[name].setValue(value) self.spin[name].blockSignals(False) + self.knob_side_btn.setText(f"Knobs: {self.state.layout.knob_side}") + self.knob_side_btn.setEnabled(bool(self.state.layout.knobs)) self._say("") elif not keep: self._set_layout(self.editing_from) @@ -1225,6 +1276,12 @@ class Window(QMainWindow): self.pad.set_editing(on) self._refresh() + def _flip_knob_side(self): + side = "left" if self.state.layout.knob_side == "right" else "right" + self._set_layout(self.state.layout.with_knobs_on(side)) + self.knob_side_btn.setText(f"Knobs: {side}") + self.pad.set_editing(True) + def _set_layout(self, layout): self.state.layout = layout self.pad.set_layout(layout) diff --git a/macropad_gui/core.py b/macropad_gui/core.py index d77864a..987f91b 100644 --- a/macropad_gui/core.py +++ b/macropad_gui/core.py @@ -79,12 +79,15 @@ class Layout: cols: int = 4 name: str = "" places: tuple = () # ((row, col), ...) per key; empty means left to right + knob_side: str = "right" # which side of the keys the knobs sit on def __post_init__(self): if not 1 <= self.keys <= proto.KEY_SLOTS: raise ValueError(f"a pad can have 1 to {proto.KEY_SLOTS} keys") if not 0 <= self.knobs <= proto.KNOB_SLOTS: raise ValueError(f"a pad can have up to {proto.KNOB_SLOTS} knobs") + if self.knob_side not in ("left", "right"): + raise ValueError("knobs sit on the left or the right") if self.rows * self.cols < self.keys: raise ValueError(f"{self.rows}x{self.cols} can't hold " f"{self.keys} keys") @@ -108,6 +111,16 @@ class Layout: """The same pad with its keys in different spots.""" return replace(self, places=tuple((int(r), int(c)) for r, c in places)) + def with_knobs_on(self, side): + """ + The same pad with its knobs down the other side. + + Sides are named for the pad lying flat. Standing it upright turns + the whole board, so knobs on the right end up along the top and + knobs on the left along the bottom. + """ + return replace(self, knob_side=side) + def resized(self, rows=None, cols=None, keys=None, knobs=None): """ Change the grid, keeping any arrangement that still fits and @@ -162,16 +175,21 @@ class Layout: def to_json(self): d = {"keys": self.keys, "knobs": self.knobs, - "rows": self.rows, "cols": self.cols, "name": self.name} + "rows": self.rows, "cols": self.cols, "name": self.name, + "knob_side": self.knob_side} if self.places: d["places"] = [list(p) for p in self.places] return d @classmethod def from_json(cls, d): + # knob_side is newer than the file format. Files written before it + # existed describe pads with knobs on the right, which was the only + # thing the drawing could do. return cls(keys=int(d["keys"]), knobs=int(d["knobs"]), rows=int(d["rows"]), cols=int(d["cols"]), name=d.get("name", ""), + knob_side=d.get("knob_side", "right"), places=tuple(tuple(p) for p in d.get("places", ()))) @@ -747,16 +765,22 @@ def decode_info(report): return report[2], report[3] -def decode_config(report): +def decode_config(report, layout=None): """ The inverse of Binding.reports()[0]: (control, layer, Binding) from a native config report, or None if it isn't one we understand. Used to import captures and to prove round trips in the tests. + + layout decides which slots count as real controls, and it matters more + than it looks. The firmware answers for 15 keys and 3 knobs whatever + the pad actually has, so the caller has to say how big the pad is. + Decode a 15 key pad against the default 12 key layout and keys 13 to 15 + and the whole third knob are dropped on the floor without a word. """ 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] - control = CONTROL_FOR_ACTION.get(action) + control = (layout or DEFAULT_LAYOUT).control_for_action.get(action) if control is None: return None delay = report[5] | (report[6] << 8) @@ -816,7 +840,7 @@ def read_capture(path, incoming=False): return out -def import_capture(path, state): +def import_capture(path, state, layout=None): """ Record what a vendor-software session wrote, so the GUI knows what's on the pad. Only bindings followed by a commit count - the vendor app can @@ -828,7 +852,7 @@ def import_capture(path, state): done.extend(pending) pending = [] continue - decoded = decode_config(report) + decoded = decode_config(report, layout or state.layout) if decoded: pending = [p for p in pending if p[:2] != decoded[:2]] + [decoded] for control, layer, binding in done: @@ -907,11 +931,11 @@ def read_info(device): return None -def read_layer(device, layer=1): +def read_layer(device, layer=1, layout=None): """{control: Binding} for one layer, as it is on the pad right now.""" out = {} for reply in _exchange(device.path, proto.native_read(device.report_id, layer)): - decoded = decode_config(reply) + decoded = decode_config(reply, layout) if not decoded: continue control, got_layer, binding = decoded @@ -920,9 +944,9 @@ def read_layer(device, layer=1): return out -def read_into_state(device, state, layer=1): +def read_into_state(device, state, layer=1, layout=None): """Read the pad and make the shadow state match it. Returns what was read.""" - bindings = read_layer(device, layer) + bindings = read_layer(device, layer, layout or state.layout) if not bindings: raise ReadError("the pad didn't answer") for control, binding in bindings.items(): @@ -948,12 +972,17 @@ class Detection: """ A first guess at the shape. The pad reports counts, not geometry, so rows and columns are a guess the owner should confirm. + + Pads in this family are wider than they are tall, so the guess is + the squarest split that keeps rows no greater than columns: 6 keys + give 2x3, 12 give 3x4, 15 give 3x5. The old version had this the + wrong way round and drew every pad on its side. """ if not self.speaks: return None - cols = 3 if self.keys % 3 == 0 else (4 if self.keys % 4 == 0 else self.keys) - rows = -(-self.keys // cols) - return Layout(self.keys, self.knobs, rows, cols) + rows = max(r for r in range(1, int(self.keys ** 0.5) + 1) + if self.keys % r == 0) + return Layout(self.keys, self.knobs, rows, self.keys // rows) def detect(device): @@ -995,7 +1024,7 @@ def detect(device): if action not in expected or layer != 1: continue control = probe.control_for_action[action] - decoded = decode_config(reply) + decoded = decode_config(reply, probe) if decoded: bindings[control] = decoded[2] diff --git a/macropad_gui/padview.py b/macropad_gui/padview.py index 3c7f6a5..241d96e 100644 --- a/macropad_gui/padview.py +++ b/macropad_gui/padview.py @@ -203,19 +203,40 @@ class PadView(QWidget): # from a knob's centre down to the bottom of its legend rows stack = R_OUT + ROW_GAP + len(core.DIAL_ACTIONS) * ROW_H + # Sides are named for the pad lying flat, so standing it upright + # turns them: knobs on the right go along the top, knobs on the + # left along the bottom. + left = lay.knob_side == "left" and bool(lay.knobs) + + keys_w = grid_cols * (K + G) - G + keys_h = grid_rows * (K + G) - G + # how much room the knobs need across, and down from the first centre + dials_w = lay.knobs * (DIAL_W + G) - G if lay.knobs else 0 + dials_h = (R_OUT + (lay.knobs - 1) * (stack + 20 + R_OUT) + stack + if lay.knobs else 0) + if self.orientation == "upright": - # knobs in a row above the keys - span = max(grid_cols * (K + G) - G, lay.knobs * (DIAL_W + G)) + # knobs in a row, above the keys or below them. Whichever of the + # two is narrower gets centred against the other. + span = max(keys_w, dials_w) step = span / max(lay.knobs, 1) - dial_centres = [QPointF(M + step * (i + 0.5), M + R_OUT) + band = (R_OUT + stack + 28) if lay.knobs else 0 + grid_left = M + max(0.0, (span - keys_w) / 2) + grid_top = M if left else M + band + keys_bottom = grid_top + keys_h + y = (keys_bottom + 28 + R_OUT) if left else (M + R_OUT) + dial_centres = [QPointF(M + step * (i + 0.5), y) for i in range(lay.knobs)] - grid_top = M + (R_OUT + stack + 28 if lay.knobs else 0) - grid_left = M else: - # knobs in a column down the right - grid_top, grid_left = M, M - x = M + grid_cols * (K + G) + 30 + DIAL_W / 2 - dial_centres = [QPointF(x, M + R_OUT + i * (stack + 20 + R_OUT)) + # knobs in a column, down whichever side the pad wears them. + # Three knobs of legends are taller than three rows of keys, so + # centre the shorter one rather than leaving a hole in the board. + grid_top = M + max(0.0, (dials_h - keys_h) / 2) + grid_left = M + (DIAL_W + 30 if left else 0) + x = (M + DIAL_W / 2) if left else \ + (grid_left + keys_w + G + 30 + DIAL_W / 2) + top = M + max(0.0, (keys_h - dials_h) / 2) + dial_centres = [QPointF(x, top + R_OUT + i * (stack + 20 + R_OUT)) for i in range(lay.knobs)] self.cells = {} diff --git a/tests/test_core.py b/tests/test_core.py index b8d4784..9dd8b48 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -218,6 +218,23 @@ class ReadingThePad(unittest.TestCase): self.assertEqual(phantom, {0x0d, 0x0e, 0x0f, # keys 13 to 15 0x16, 0x17, 0x18}) # a third knob + def test_the_same_replies_decode_fully_for_a_bigger_pad(self): + """ + Those six slots are only phantom because THIS pad is 12 keys and 2 + knobs. A 15 key, 3 knob sibling shares the USB id and answers the + same 24 slots for real, so decoding has to follow the layout rather + than a module level constant. + """ + big = core.Layout(15, 3, 3, 5) + configs = [r for r in self.got if r[1] == proto.MAGIC_READ] + decoded = [core.decode_config(r, big) for r in configs] + self.assertEqual(sum(d is None for d in decoded), 0) + + layer1 = {d[0] for d in decoded if d[1] == 1} + self.assertEqual(layer1, set(big.control_ids)) + self.assertIn("key15", layer1) + self.assertIn("dial3-right", layer1) + def test_reading_fills_in_the_state(self): from unittest import mock replies = [r for r in self.got if r[1] == proto.MAGIC_READ and r[3] == 1] @@ -233,6 +250,168 @@ class ReadingThePad(unittest.TestCase): core.Binding(media="calculator")) +class KnobSide(unittest.TestCase): + """ + Not every pad wears its knobs on the right. accdias's 15 key pad has + three down the left, one above two. + """ + + def test_default_is_right(self): + self.assertEqual(core.Layout().knob_side, "right") + self.assertEqual(core.DEFAULT_LAYOUT.knob_side, "right") + + def test_flip_and_back(self): + lay = core.Layout(15, 3, 3, 5) + self.assertEqual(lay.with_knobs_on("left").knob_side, "left") + self.assertEqual(lay.with_knobs_on("left").with_knobs_on("right"), lay) + + def test_nonsense_side_is_refused(self): + with self.assertRaises(ValueError): + core.Layout(15, 3, 3, 5, knob_side="up") + + def test_survives_a_round_trip(self): + lay = core.Layout(15, 3, 3, 5).with_knobs_on("left") + self.assertEqual(core.Layout.from_json(json.loads(json.dumps(lay.to_json()))), + lay) + + def test_old_files_still_load_as_right(self): + old = {"keys": 12, "knobs": 2, "rows": 3, "cols": 4, "name": ""} + self.assertEqual(core.Layout.from_json(old).knob_side, "right") + + def test_resizing_keeps_the_side(self): + lay = core.Layout(15, 3, 3, 5).with_knobs_on("left") + self.assertEqual(lay.resized(keys=12, knobs=2).knob_side, "left") + + def test_the_drawing_actually_moves(self): + try: + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + from PySide6.QtWidgets import QApplication + from macropad_gui import padview + except ImportError: + self.skipTest("PySide6 not installed") + QApplication.instance() or QApplication([]) + view = padview.PadView() + view.resize(1000, 700) + + def dial_x(side): + lay = core.Layout(15, 3, 3, 5).with_knobs_on(side) + view.set_layout(lay) + view.set_orientation("flat") + return (view.dials["dial1"].x(), + min(r.x() for r in view.keys.values())) + + knob_r, keys_r = dial_x("right") + knob_l, keys_l = dial_x("left") + self.assertGreater(knob_r, keys_r, "knobs should sit right of the keys") + self.assertLess(knob_l, keys_l, "knobs should sit left of the keys") + + +class BoardBalance(unittest.TestCase): + """ + Three knobs' worth of legends is taller than three rows of keys. The + shorter of the two gets centred against the other, so the board has no + large empty corner. + """ + + def view(self, layout, orientation="flat"): + try: + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + from PySide6.QtWidgets import QApplication + from macropad_gui import padview + except ImportError: + self.skipTest("PySide6 not installed") + QApplication.instance() or QApplication([]) + v = padview.PadView() + v.resize(1000, 700) + v.set_layout(layout) + v.set_orientation(orientation) + return v + + def middles(self, v): + from macropad_gui import padview + keys = list(v.keys.values()) + key_mid = (min(r.top() for r in keys) + max(r.bottom() for r in keys)) / 2 + # dials are centres, not rectangles; their legends hang underneath + dial_top = min(c.y() for c in v.dials.values()) - padview.R_OUT + dial_bottom = max(r.bottom() for r in v.rows.values()) + return key_mid, (dial_top + dial_bottom) / 2 + + def test_keys_and_knobs_share_a_centre_line(self): + for side in ("left", "right"): + v = self.view(core.Layout(15, 3, 3, 5).with_knobs_on(side)) + key_mid, dial_mid = self.middles(v) + self.assertLess(abs(key_mid - dial_mid), 60, + f"knobs on the {side}: centres {key_mid} vs {dial_mid}") + + def test_no_large_empty_band_under_the_keys(self): + v = self.view(core.Layout(15, 3, 3, 5).with_knobs_on("left")) + keys_bottom = max(r.bottom() for r in v.keys.values()) + keys_top = min(r.top() for r in v.keys.values()) + below = v.board.bottom() - keys_bottom + above = keys_top - v.board.top() + # the gap under the keys used to be the whole dial column + self.assertLess(below, above + 140) + + def test_a_tall_pad_still_works(self): + v = self.view(core.Layout(15, 1, 5, 3)) + key_mid, dial_mid = self.middles(v) + self.assertLess(abs(key_mid - dial_mid), 60) + + +class KeypadKeys(unittest.TestCase): + """ + The capture shows this pad left the factory with keypad combinations on + key slots 13 to 15. We could send those bytes but not name them, so a + read came back as an unknown binding. + """ + + def test_the_factory_bindings_in_the_capture_now_have_names(self): + reports = core.read_capture( + str(CAPTURES / "1189-8840-read.txt"), incoming=True) + big = core.Layout(15, 3, 3, 5) + for report in reports: + if report[1] != proto.MAGIC_READ: + continue + self.assertIsNotNone(core.decode_config(report, big), + f"action 0x{report[2]:02x} still has no name") + + def test_round_trip(self): + for name in ("numpad0", "numpad5", "numpad9", "numpadplus", + "numpadenter", "numpaddot", "numlock"): + b = core.Binding(keys=name) + report = b.reports(3, core.action_byte("key1"), 1)[0] + self.assertEqual(core.decode_config(report), ("key1", 1, b), name) + + def test_they_are_distinct_from_the_number_row(self): + for i in range(10): + self.assertNotEqual(proto.KEYCODES[str(i)], + proto.KEYCODES[f"numpad{i}"]) + + +class ShapeGuess(unittest.TestCase): + """ + The pad reports counts, never geometry. The guess has to land wider + than it is tall, because every pad in this family is. + """ + + def guess(self, keys, knobs): + d = core.Detection(speaks=True, keys=keys, knobs=knobs) + return d.layout.rows, d.layout.cols + + def test_real_pads(self): + self.assertEqual(self.guess(12, 2), (3, 4)) # the pad this was built on + self.assertEqual(self.guess(15, 3), (3, 5)) # accdias's, confirmed by him + self.assertEqual(self.guess(9, 1), (3, 3)) + self.assertEqual(self.guess(6, 1), (2, 3)) + self.assertEqual(self.guess(3, 1), (1, 3)) + + def test_never_taller_than_wide(self): + for keys in range(1, proto.KEY_SLOTS + 1): + rows, cols = self.guess(keys, 0) + self.assertLessEqual(rows, cols, f"{keys} keys guessed {rows}x{cols}") + self.assertEqual(rows * cols, keys) + + class Detection(unittest.TestCase): """ Detecting an unknown pad, using the real capture as the stand-in for a @@ -257,7 +436,9 @@ class Detection(unittest.TestCase): d = self.run_detect() self.assertTrue(d.speaks, d.why) self.assertEqual((d.keys, d.knobs), (12, 2)) - self.assertEqual(d.layout, core.Layout(12, 2, 4, 3)) + # 3 rows of 4, the way the pad is actually printed. The guess used + # to come out transposed and every pad was drawn on its side. + self.assertEqual(d.layout, core.Layout(12, 2, 3, 4)) self.assertEqual(d.bindings["dial1-left"], core.Binding(media="volumedown")) def test_silence_is_not_taken_as_yes(self):