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:
@@ -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 = {}
|
||||
|
||||
Reference in New Issue
Block a user