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:
13
macropad.py
13
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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user