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.
This commit is contained in:
Mohamed Mahjoub
2026-09-28 22:01:48 +01:00
committed by GitHub
parent 8a18015c34
commit 6e25e350e0
5 changed files with 327 additions and 26 deletions

View File

@@ -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,

View File

@@ -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)

View File

@@ -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]

View File

@@ -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 = {}

View File

@@ -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):