Compare commits

4 Commits

Author SHA1 Message Date
625c95f09c Lighting menu in the window for pads that have backlight modes
A backend that lists lighting_modes gets a Lighting button in the footer:
one entry per mode and a colour picker for the custom one. The tick
follows what the pad reported in its last read, since every read of an
ITPROTECH pad carries the lighting along with the keys.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-23 19:37:44 +02:00
4f33161c70 CLI state and write follow the pad that is plugged in
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-23 19:29:42 +02:00
021f9f7fa1 Lighting as a macropad subcommand; knobless pads start in the flat view
Running the backend module directly made runpy warn, since the package
had already imported it. The lighting commands now sit with doctor, state
and write. A pad without knobs lies flat on the desk, so it is drawn flat
unless an orientation has been chosen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-23 19:26:46 +02:00
80c6a5da1c Add the ITPROTECH MK series (8808) as a third pad family
Confirmed on the MK-09 (8808:660a, 9 keys): the vendor's .NET configurator
was decompiled for the wire format and every frame was then written to a
real pad and read back. Keys, media keys and multikey sequences are
supported; lighting from the command line.

The firmware acts on every other output report, so each report is sent
twice (as the vendor software does) and every write is verified by reading
the key back. The app now keeps one state file per pad and starts with a
known layout for a known USB id, and the editor asks the backend whether a
binding fits before offering to write it. The actions listener and the
udev rule cover every supported vendor.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-23 19:24:30 +02:00
18 changed files with 1292 additions and 30 deletions

View File

@@ -58,6 +58,9 @@ Bus 001 Device 011: ID 1189:8840 Acer Communications & Multimedia USB Composite
The important bit is **`1189:8840`**. Don't worry about the "Acer" part: it isn't made by Acer, these pads just borrow that ID. The important bit is **`1189:8840`**. Don't worry about the "Acer" part: it isn't made by Acer, these pads just borrow that ID.
> [!TIP]
> **`8808:660a` with no name next to it?** That's the ITPROTECH MK-09, the Japanese 9 key pad (ちょい足しキーボード). MacroPad supports it too; see [ITPROTECH MK series](#-itprotech-mk-series-8808).
> [!NOTE] > [!NOTE]
> **Different number, like `1189:8890` or `1189:8842`?** That's a sibling of this pad with a different layout. MacroPad will spot it and tell you it isn't supported yet, rather than risk sending it the wrong thing. [ch57x-keyboard-tool](https://github.com/kriomant/ch57x-keyboard-tool) supports several of them today, and [you can help add yours here](#-other-pads-in-this-family). > **Different number, like `1189:8890` or `1189:8842`?** That's a sibling of this pad with a different layout. MacroPad will spot it and tell you it isn't supported yet, rather than risk sending it the wrong thing. [ch57x-keyboard-tool](https://github.com/kriomant/ch57x-keyboard-tool) supports several of them today, and [you can help add yours here](#-other-pads-in-this-family).
@@ -95,7 +98,7 @@ Both write straight to the pad's own memory, so you can even use both. Pick whic
| | MacroPad | [ch57x-keyboard-tool](https://github.com/kriomant/ch57x-keyboard-tool) | | | MacroPad | [ch57x-keyboard-tool](https://github.com/kriomant/ch57x-keyboard-tool) |
|---|---|---| |---|---|---|
| How you use it | Click on the UI of your pad | Write a YAML file, run a command | | How you use it | Click on the UI of your pad | Write a YAML file, run a command |
| Supported Pads | `1189:8840` (12 keys, 2 knobs) | `1189:8840`, `8842`, `8890`, several layouts | | Supported Pads | `1189:8840` (12 keys, 2 knobs), `8808:660a` (ITPROTECH 9 keys) | `1189:8840`, `8842`, `8890`, several layouts |
| Operating Systems | Linux | Linux, macOS, Windows | | Operating Systems | Linux | Linux, macOS, Windows |
| Shortcuts | Yes | Yes | | Shortcuts | Yes | Yes |
| Media Keys | Yes | Yes | | Media Keys | Yes | Yes |
@@ -430,6 +433,27 @@ If you'd like your pad in MacroPad too, you'll need the vendor software (`MINI_K
6. **[Open an issue](https://github.com/Probler-Yt/MacroPad/issues)** with that file, your `macropad-probe.py` output, and a photo of your pad. That's genuinely all it takes to get your pad supported. 6. **[Open an issue](https://github.com/Probler-Yt/MacroPad/issues)** with that file, your `macropad-probe.py` output, and a photo of your pad. That's genuinely all it takes to get your pad supported.
## 🇯🇵 ITPROTECH MK series (8808)
The **ITPROTECH ちょい足しキーボード** pads sold in Japan (MK-02, MK-04, MK-09 and friends, USB vendor id `8808`) speak a different protocol from the CH57x pads, and the vendor's configurator is Windows only. MacroPad talks to them natively.
**Confirmed on `8808:660a`, the MK-09 (3x3, hot-swap switches, RGB).** Keys are numbered 1 to 9 left to right, top to bottom. Shortcuts, media keys and key sequences all work and are read back from the pad after every write, so the drawing always shows what the pad really holds.
What the hardware can and can't do:
| | MK-09 |
|---|---|
| Shortcuts with modifiers | Yes |
| Media keys | Yes, any consumer usage |
| Key sequences | Yes, up to 10 keys with **one** set of modifiers for the whole sequence, no delays |
| Mouse actions | The pad can, the app doesn't offer them yet |
| Layers | None |
| Lighting | 15 modes and a custom colour, from the **Lighting** menu at the bottom of the window or the command line: `macropad lighting rgb "press: blue"`, `macropad lighting colour ff8800`, `macropad lighting show` |
Other sizes under vendor `8808` are found and offered **Ask the pad what it is**, which reads only. If the pad answers like an MK-09 does, the app adopts it. If you have one, a report from that button is all it takes to add it to the confirmed list.
How it was worked out, and the wire format, is in [docs/protocol-itprotech.md](docs/protocol-itprotech.md).
## 🪟 Windows Support? ## 🪟 Windows Support?
Windows already works with apps from vendors shipped as `.exe` files, and [ch57x-keyboard-tool](https://github.com/kriomant/ch57x-keyboard-tool). Windows already works with apps from vendors shipped as `.exe` files, and [ch57x-keyboard-tool](https://github.com/kriomant/ch57x-keyboard-tool).
@@ -623,6 +647,7 @@ tests/ real captures and the tests that replay them
- [ ] Knob press to toggle brightness between 0% and 100% - [ ] Knob press to toggle brightness between 0% and 100%
- [ ] Save and switch between named profiles (gaming, editing, streaming) - [ ] Save and switch between named profiles (gaming, editing, streaming)
- [ ] **Windows and macOS** via a cross platform USB layer - [ ] **Windows and macOS** via a cross platform USB layer
- [x] A third protocol family: ITPROTECH MK series (`8808:660a`)
- [ ] **More pads**: every capture sent in gets us closer - [ ] **More pads**: every capture sent in gets us closer
--- ---

View File

@@ -26,6 +26,8 @@ A pad from a different family fails one of those, and the app leaves it alone ra
The pad reports how many keys and knobs it has, but not how they're arranged, so the first guess at rows and columns may be wrong. Fix it with [Edit layout](#-if-the-drawing-doesnt-match-your-pad) and the drawing will match your hardware. The pad reports how many keys and knobs it has, but not how they're arranged, so the first guess at rows and columns may be wrong. Fix it with [Edit layout](#-if-the-drawing-doesnt-match-your-pad) and the drawing will match your hardware.
The same button works for the ITPROTECH pads (vendor id `8808`): the app asks the pad for its key records and adopts it if they come back cleanly.
## If it doesn't answer ## If it doesn't answer
Some pads in this family speak an older protocol and won't answer. Working those out needs a capture of the vendor software, the same process used for this one. You'll need `MINI_KEYBOARD.exe` and Wine. Some pads in this family speak an older protocol and won't answer. Working those out needs a capture of the vendor software, the same process used for this one. You'll need `MINI_KEYBOARD.exe` and Wine.

View File

@@ -0,0 +1,71 @@
# The ITPROTECH MK series protocol
The ITPROTECH ちょい足しキーボード pads (USB vendor id `8808`) are a different
family from the CH57x pads: different chip, different configurator, different
wire format. This is what an MK-09 (`8808:660a`, 9 keys) speaks. The code is in
`macropad_gui/backends/itprotech_proto.py` and `itprotech.py`.
## How it was worked out
The vendor's configurator (`mk_series_software.zip` from itprotech.jp,
`Run-me.exe`) is a .NET program, so it decompiles cleanly. Its device code
lists product ids `6600` to `6618` and one form per model; the 9 key form
builds exactly the reports below. Everything was then checked against a real
MK-09 by reading it, writing each kind of binding to key 9, and reading it
back. The responses in `tests/test_itprotech.py` are those real bytes.
## Interfaces
The pad has four HID interfaces. The first two are a boot keyboard and a boot
mouse; the third carries an NKRO keyboard, consumer control and a second
vendor page (`0xFFA0`) the configurator never uses. The fourth is the config
channel: usage page `0xFF00`, 64 byte input and output reports, **no report
id**, so a write to `/dev/hidraw` is `00` followed by 64 bytes.
## Reports
Every report, both directions, is `len payload...` zero padded to 64 bytes.
| Host sends | Meaning |
|---|---|
| `01 55` | Read. The pad answers one report per key, then `02 63 <mode>`, then `04 64 <g> <r> <b>`. |
| `0d <key> <type> ...` | Bind one key. `0d` (13) is the record length on the 9 key model; the 12 key model uses `0a`, the 8 key `0f`, and so on. The record is read off the pad before writing. |
| `02 57 <mode>` | Lighting mode 0 to 14. |
| `04 70 <g> <r> <b>` | Custom colour for mode 14. Green first. |
| `01 56` | **Firmware bootloader. Never sent by this app; `frame()` refuses it.** |
Key types:
| Type | Record | |
|---|---|---|
| `01` | `<mods> <keycode>` | keyboard; mods is the HID modifier byte |
| `02` | `<buttons> <x> <y> <wheel>` | mouse, not offered by the app |
| `03` | `04 <lo> <hi> 00` | consumer page usage, little endian |
| `04` | `<mods> <k1> ... <k10>` | several keys in a row, one modifier byte for all |
| `ff` | | a slot the vendor software never wrote |
Examples that were written to the pad and read back identically:
```
0d 09 01 00 10 key 9 -> m
0d 09 03 04 e9 00 00 key 9 -> volume up
0d 09 04 00 0b 08 0f 0f 12 key 9 -> h e l l o
```
## The every-other-report rule
The firmware acts on every **second** output report it receives, whatever the
command. Send a read once and you may get silence; send it again and the dump
arrives. The vendor software sends every command twice, 20 ms apart, which
is why it works, and so does this backend (`REPEAT = 2`). A write is then
confirmed by reading the key back; a pad that holds something else makes the
write fail loudly rather than being believed.
Reading never changes anything. There are no layers, no commit command, and
the bindings are in the pad's flash as soon as the write is accepted.
## Lighting modes
0 off, 1 flow 1, 2 to 8 light on press in red, green, blue, yellow, cyan,
purple, white, 9 flow 2, 10 flow 3, 11 irradiation, 12 colourful gradient,
13 table lamp, 14 custom colour.

View File

@@ -22,7 +22,13 @@ BIN="$HOME/.local/bin"
DESKTOP="$DATA/applications/macropad.desktop" DESKTOP="$DATA/applications/macropad.desktop"
ICON="$DATA/icons/hicolor/scalable/apps/macropad.svg" ICON="$DATA/icons/hicolor/scalable/apps/macropad.svg"
RULE="/etc/udev/rules.d/60-macropad.rules" RULE="/etc/udev/rules.d/60-macropad.rules"
RULE_LINE='KERNEL=="hidraw*", ATTRS{idVendor}=="1189", TAG+="uaccess"' # One line per pad family the app supports. CH57x (1189), SayoDevice (8089),
# ITPROTECH MK series (8808). Keep in step with macropad_gui/backends.
RULE_LINES=(
'KERNEL=="hidraw*", ATTRS{idVendor}=="1189", TAG+="uaccess"'
'KERNEL=="hidraw*", ATTRS{idVendor}=="8089", TAG+="uaccess"'
'KERNEL=="hidraw*", ATTRS{idVendor}=="8808", TAG+="uaccess"'
)
if [ -t 1 ]; then if [ -t 1 ]; then
O=$'\e[38;5;208m'; G=$'\e[32m'; R=$'\e[31m'; B=$'\e[1m'; D=$'\e[2m'; X=$'\e[0m' O=$'\e[38;5;208m'; G=$'\e[32m'; R=$'\e[31m'; B=$'\e[1m'; D=$'\e[2m'; X=$'\e[0m'
@@ -191,7 +197,11 @@ fi
# -------------------------------------------------------------- 4. permission # -------------------------------------------------------------- 4. permission
step "Letting the app talk to the pad" step "Letting the app talk to the pad"
if [ -f "$RULE" ] && grep -qF "$RULE_LINE" "$RULE"; then have_all_rules() {
[ -f "$RULE" ] || return 1
for line in "${RULE_LINES[@]}"; do grep -qF "$line" "$RULE" || return 1; done
}
if have_all_rules; then
ok "permission rule already in place" ok "permission rule already in place"
else else
say " Linux doesn't let apps write to USB devices by default. This adds one" say " Linux doesn't let apps write to USB devices by default. This adds one"
@@ -199,9 +209,9 @@ else
say " ${D}It writes: $RULE${X}" say " ${D}It writes: $RULE${X}"
if ask "Add it now? This asks for your password."; then if ask "Add it now? This asks for your password."; then
sudo mkdir -p "$(dirname "$RULE")" sudo mkdir -p "$(dirname "$RULE")"
printf '%s\n' "# MacroPad: let the logged-in user configure 1189:8840 macro pads." \ printf '%s\n' "# MacroPad: let the logged-in user configure the macro pads it supports." \
"# Must sort before 73-seat-late.rules, which is where uaccess takes effect." \ "# Must sort before 73-seat-late.rules, which is where uaccess takes effect." \
"$RULE_LINE" | sudo tee "$RULE" >/dev/null "${RULE_LINES[@]}" | sudo tee "$RULE" >/dev/null
if command -v udevadm >/dev/null; then if command -v udevadm >/dev/null; then
sudo udevadm control --reload-rules || true sudo udevadm control --reload-rules || true
sudo udevadm trigger --subsystem-match=hidraw || true sudo udevadm trigger --subsystem-match=hidraw || true
@@ -217,7 +227,7 @@ fi
for f in /etc/udev/rules.d/*.rules; do for f in /etc/udev/rules.d/*.rules; do
[ -e "$f" ] || continue [ -e "$f" ] || continue
[ "$f" = "$RULE" ] && continue [ "$f" = "$RULE" ] && continue
if grep -qiE 'idVendor\}=="1189"' "$f" 2>/dev/null && grep -q uaccess "$f"; then if grep -qiE 'idVendor\}=="(1189|8089|8808)"' "$f" 2>/dev/null && grep -q uaccess "$f"; then
base="$(basename "$f")" base="$(basename "$f")"
if [[ ! "$base" < "73-seat-late.rules" ]]; then if [[ ! "$base" < "73-seat-late.rules" ]]; then
say " ${D}Note: $f also mentions this pad but is read too late to do" say " ${D}Note: $f also mentions this pad but is read too late to do"

View File

@@ -91,6 +91,10 @@ MEDIA_KEYS = {
"brightnessup": 0x006F, "brightnessdown": 0x0070, "brightnessup": 0x006F, "brightnessdown": 0x0070,
"calculator": 0x0192, "browser": 0x0196, "mail": 0x018A, "calculator": 0x0192, "browser": 0x0196, "mail": 0x018A,
"mycomputer": 0x0194, # seen in capture: the vendor app's "My computer" "mycomputer": 0x0194, # seen in capture: the vendor app's "My computer"
# The ITPROTECH software offers these too. Standard consumer page usages.
"mediaselect": 0x0183, "wwwhome": 0x0223, "back": 0x0224,
"forward": 0x0225, "refresh": 0x0227,
"bassup": 0x0152, "bassdown": 0x0153, "trebleup": 0x0154, "trebledown": 0x0155,
} }
# Physical control -> protocol action byte. # Physical control -> protocol action byte.

View File

@@ -7,6 +7,9 @@ The rest are for poking at the core from a terminal:
python3 -m macropad_gui state python3 -m macropad_gui state
python3 -m macropad_gui write key1 "ctrl+c" python3 -m macropad_gui write key1 "ctrl+c"
python3 -m macropad_gui write dial1-right --media volumeup python3 -m macropad_gui write dial1-right --media volumeup
python3 -m macropad_gui lighting show ITPROTECH pads only
python3 -m macropad_gui lighting rgb "press: blue"
python3 -m macropad_gui lighting colour ff8800
""" """
import argparse import argparse
@@ -32,8 +35,20 @@ def cmd_doctor(_):
return 0 if d.ready else 1 return 0 if d.ready else 1
def _state_for(device):
"""The shadow state of the pad that is plugged in, or the default one."""
if device is None:
return core.State()
state = core.State(device_id=f"{device.vid}:{device.pid}")
if not state.path.exists():
known = core.KNOWN_LAYOUTS.get((device.vid, device.pid))
if known:
state.layout = known
return state
def cmd_state(_): def cmd_state(_):
s = core.State() s = _state_for(core.diagnose(keyd=False).device)
print(f"{DIM}{s.path}{'' if s.path.exists() else ' (not created yet)'}{O}\n") print(f"{DIM}{s.path}{'' if s.path.exists() else ' (not created yet)'}{O}\n")
print(f"{DIM}layout: {s.layout.describe()}{O}\n") print(f"{DIM}layout: {s.layout.describe()}{O}\n")
for c in s.layout.control_ids: for c in s.layout.control_ids:
@@ -74,9 +89,6 @@ def cmd_actions(a):
def cmd_write(a): def cmd_write(a):
if a.control not in core.CONTROL_IDS:
print(f"Unknown control. One of: {', '.join(core.CONTROL_IDS)}")
return 1
try: try:
b = core.Binding(keys=a.keys or "", media=a.media or "", b = core.Binding(keys=a.keys or "", media=a.media or "",
delay=a.delay).validate() delay=a.delay).validate()
@@ -88,7 +100,10 @@ def cmd_write(a):
if not d.ready: if not d.ready:
return cmd_doctor(None) return cmd_doctor(None)
state = core.State() state = _state_for(d.device)
if a.control not in state.layout.control_ids:
print(f"Unknown control. One of: {', '.join(state.layout.control_ids)}")
return 1
try: try:
core.write_binding(d.device, a.control, b, state) core.write_binding(d.device, a.control, b, state)
except core.WriteError as e: except core.WriteError as e:
@@ -100,6 +115,46 @@ def cmd_write(a):
return 0 return 0
def cmd_lighting(a):
"""Lighting mode and custom colour on pads whose backend has them."""
d = core.diagnose(keyd=False)
backend = core.backend_for(d.device) if d.device else None
if not d.device or not hasattr(backend, "set_lighting"):
print(f"{R}No pad with lighting control found.{O} "
+ (d.headline if not d.device else f"{backend.name} has no lighting here."))
return 1
from .backends import itprotech_proto as wire
modes = ", ".join(f"{i} {m}" for i, m in enumerate(wire.RGB_MODES))
if a.what == "show":
mode, colour = backend.lighting(d.device)
name = wire.RGB_MODES[mode] if mode is not None and mode < len(wire.RGB_MODES) else "?"
hexcol = "#%02x%02x%02x" % colour if colour else "?"
print(f"lighting {mode} ({name}), custom colour {hexcol}")
return 0
if not d.ready:
return cmd_doctor(None)
if a.what == "rgb":
want = (a.value or "").lower()
if want.isdigit() and int(want) < len(wire.RGB_MODES):
mode = int(want)
elif want in wire.RGB_MODES:
mode = wire.RGB_MODES.index(want)
else:
print(f"Pick a mode: {modes}")
return 1
backend.set_lighting(d.device, mode)
print(f"{G}Lighting set to {mode} ({wire.RGB_MODES[mode]}).{O}")
return 0
hexcol = (a.value or "").lstrip("#")
if len(hexcol) != 6 or any(c not in "0123456789abcdefABCDEF" for c in hexcol):
print("Give the colour as RRGGBB, e.g. ff8800")
return 1
rgb = tuple(int(hexcol[i:i + 2], 16) for i in (0, 2, 4))
backend.set_lighting(d.device, wire.RGB_CUSTOM, rgb)
print(f"{G}Custom colour #{hexcol.lower()} set (mode {wire.RGB_CUSTOM}).{O}")
return 0
def main(): def main():
ap = argparse.ArgumentParser(prog="python3 -m macropad_gui") ap = argparse.ArgumentParser(prog="python3 -m macropad_gui")
sub = ap.add_subparsers(dest="cmd") sub = ap.add_subparsers(dest="cmd")
@@ -114,6 +169,9 @@ def main():
w.add_argument("keys", nargs="?", help="e.g. 'ctrl+c' or 'a,b,c'") w.add_argument("keys", nargs="?", help="e.g. 'ctrl+c' or 'a,b,c'")
w.add_argument("--media", help="e.g. volumeup") w.add_argument("--media", help="e.g. volumeup")
w.add_argument("--delay", type=int, default=0) w.add_argument("--delay", type=int, default=0)
li = sub.add_parser("lighting", help="lighting mode and colour (ITPROTECH pads)")
li.add_argument("what", choices=("show", "rgb", "colour", "color"))
li.add_argument("value", nargs="?", help="a mode number or name, or RRGGBB")
a = ap.parse_args() a = ap.parse_args()
if a.cmd is None: if a.cmd is None:
try: try:
@@ -124,7 +182,8 @@ def main():
return 1 return 1
return run() return run()
return {"doctor": cmd_doctor, "state": cmd_state, "write": cmd_write, return {"doctor": cmd_doctor, "state": cmd_state, "write": cmd_write,
"detect": cmd_detect, "actions": cmd_actions}[a.cmd](a) "detect": cmd_detect, "actions": cmd_actions,
"lighting": cmd_lighting}[a.cmd](a)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -501,8 +501,13 @@ def _descriptor_keyboard_report_id(raw):
return False, 0 return False, 0
def find_keyboard_nodes(vid=core.VID): def find_keyboard_nodes(vid=None):
"""[(hidraw path, keyboard report id)] for every pad keyboard interface.""" """
[(hidraw path, keyboard report id)] for every pad keyboard interface.
vid narrows it to one vendor; the default is every family we support.
"""
want = {vid.lower()} if vid else core.vendors()
out = [] out = []
for node in sorted(glob.glob("/sys/class/hidraw/hidraw*")): for node in sorted(glob.glob("/sys/class/hidraw/hidraw*")):
dev = os.path.join(node, "device") dev = os.path.join(node, "device")
@@ -513,7 +518,7 @@ def find_keyboard_nodes(vid=core.VID):
continue continue
hid_id = next((l.split("=", 1)[1] for l in uevent.splitlines() hid_id = next((l.split("=", 1)[1] for l in uevent.splitlines()
if l.startswith("HID_ID=")), "") if l.startswith("HID_ID=")), "")
if hid_id.count(":") != 2 or hid_id.split(":")[1][-4:].lower() != vid: if hid_id.count(":") != 2 or hid_id.split(":")[1][-4:].lower() not in want:
continue continue
is_kbd, rid = _descriptor_keyboard_report_id(raw) is_kbd, rid = _descriptor_keyboard_report_id(raw)
if is_kbd: if is_kbd:

View File

@@ -17,7 +17,7 @@ from PySide6.QtWidgets import (QApplication, QButtonGroup, QFileDialog, QFrame,
QGridLayout, QHBoxLayout, QLabel, QLineEdit, QGridLayout, QHBoxLayout, QLabel, QLineEdit,
QMainWindow, QMessageBox, QPlainTextEdit, QMainWindow, QMessageBox, QPlainTextEdit,
QPushButton, QSpinBox, QVBoxLayout, QWidget, QPushButton, QSpinBox, QVBoxLayout, QWidget,
QCheckBox, QComboBox, QMenu) QCheckBox, QColorDialog, QComboBox, QMenu)
from . import __version__, actions as act, core, keymap, padview, themes from . import __version__, actions as act, core, keymap, padview, themes
from .padview import INK, INK_DIM, TAPE, WINDOW, Look, PadView from .padview import INK, INK_DIM, TAPE, WINDOW, Look, PadView
@@ -243,6 +243,7 @@ class Inspector(QWidget):
self.control = None self.control = None
self._loading = False self._loading = False
self.editing_layout = False self.editing_layout = False
self.backend = None # the family of the pad plugged in, if any
v = QVBoxLayout(self) v = QVBoxLayout(self)
v.setContentsMargins(24, 24, 24, 16) v.setContentsMargins(24, 24, 24, 16)
@@ -596,13 +597,20 @@ class Inspector(QWidget):
self.keys_hint.setText(f"<span style='color:{BAD}'>{e}</span>") self.keys_hint.setText(f"<span style='color:{BAD}'>{e}</span>")
self.changed.emit(self.control, None) self.changed.emit(self.control, None)
return return
if b.is_sequence and not core.SEQUENCES_VERIFIED: sequences_ok = (self.backend.sequences if self.backend is not None
else core.SEQUENCES_VERIFIED)
if b.is_sequence and not sequences_ok:
self.keys_hint.setText( self.keys_hint.setText(
f"<span style='color:{BAD}'>Sequences aren't verified on this " f"<span style='color:{BAD}'>Sequences aren't verified on this "
"pad yet, so they're switched off for now. One shortcut per " "pad yet, so they're switched off for now. One shortcut per "
"control.</span>") "control.</span>")
self.changed.emit(self.control, None) self.changed.emit(self.control, None)
return return
why = self.backend.check(b) if self.backend is not None else None
if why:
self.keys_hint.setText(f"<span style='color:{BAD}'>{why}</span>")
self.changed.emit(self.control, None)
return
self.keys_hint.setText(f"Sends {b.label()}") self.keys_hint.setText(f"Sends {b.label()}")
self.changed.emit(self.control, b) self.changed.emit(self.control, b)
@@ -943,6 +951,20 @@ class Window(QMainWindow):
stamp = self.theme_menu.addAction(f"MacroPad {__version__}") stamp = self.theme_menu.addAction(f"MacroPad {__version__}")
stamp.setEnabled(False) stamp.setEnabled(False)
self.theme_btn.setMenu(self.theme_menu) self.theme_btn.setMenu(self.theme_menu)
# Lighting is pad wide, so it lives down here with the other things
# that aren't about one key. Only pads whose backend offers modes
# get the button; it is filled in when such a pad turns up.
self.light_btn = _button("Lighting", "flat")
self.light_btn.setToolTip("Backlight mode and colour, stored on the pad")
self.light_menu = QMenu(self)
self.light_group = QActionGroup(self)
self.light_group.setExclusive(True)
self.light_actions = {}
self.light_custom = None
self.light_backend = None
self.light_colour = None
self.light_btn.setMenu(self.light_menu)
self.light_btn.hide()
self.rotate = _button("Rotate pad", "flat") self.rotate = _button("Rotate pad", "flat")
self.import_btn = _button("Import capture…", "flat") self.import_btn = _button("Import capture…", "flat")
self.write_all = _button("", "primary") self.write_all = _button("", "primary")
@@ -953,6 +975,7 @@ class Window(QMainWindow):
fl.addWidget(self.edit_btn) fl.addWidget(self.edit_btn)
fl.addWidget(self.read_btn) fl.addWidget(self.read_btn)
fl.addWidget(self.theme_btn) fl.addWidget(self.theme_btn)
fl.addWidget(self.light_btn)
fl.addWidget(self.rotate) fl.addWidget(self.rotate)
fl.addWidget(self.import_btn) fl.addWidget(self.import_btn)
fl.addWidget(self.write_all) fl.addWidget(self.write_all)
@@ -976,6 +999,7 @@ class Window(QMainWindow):
self.rotate.clicked.connect(self._rotate) self.rotate.clicked.connect(self._rotate)
self.style_group.triggered.connect(lambda a: self._set_theme(a.data())) self.style_group.triggered.connect(lambda a: self._set_theme(a.data()))
self.colour_group.triggered.connect(lambda a: self._set_theme(a.data())) self.colour_group.triggered.connect(lambda a: self._set_theme(a.data()))
self.light_group.triggered.connect(lambda a: self._set_lighting(a.data()))
self.import_btn.clicked.connect(self._import) self.import_btn.clicked.connect(self._import)
self.note = core._keyd_warning() self.note = core._keyd_warning()
@@ -1170,6 +1194,7 @@ class Window(QMainWindow):
f"{d.device.vid}:{d.device.pid}" in self.adopted: f"{d.device.vid}:{d.device.pid}" in self.adopted:
self.diag = core.Diagnosis("ready", f"Ready on {d.device.path}.", self.diag = core.Diagnosis("ready", f"Ready on {d.device.path}.",
device=d.device, warnings=d.warnings) device=d.device, warnings=d.warnings)
switched = self._follow_device()
colour = {"ready": GOOD, "unplugged": INK_DIM.name()}.get(self.diag.status, BAD) colour = {"ready": GOOD, "unplugged": INK_DIM.name()}.get(self.diag.status, BAD)
self.dot.setStyleSheet(f"background: {colour}; border-radius: 4px;") self.dot.setStyleSheet(f"background: {colour}; border-radius: 4px;")
if self.diag.device: if self.diag.device:
@@ -1186,11 +1211,47 @@ class Window(QMainWindow):
self.inspector.show_problem(self.diag, self.note or self.shape_note, self.inspector.show_problem(self.diag, self.note or self.shape_note,
offer_detect=bool(self.shape_note)) offer_detect=bool(self.shape_note))
self.inspector.show_background(bool(act.listener_pid())) self.inspector.show_background(bool(act.listener_pid()))
if self.diag.status != old: if self.diag.status != old or switched:
self._refresh(editor=False) self._refresh(editor=switched)
if self.diag.ready and old in ("unplugged", "no-interface", None): if self.diag.ready and (switched or
old in ("unplugged", "no-interface", None)):
self._read(quiet=True) self._read(quiet=True)
def _follow_device(self):
"""
Keep the shadow state and the drawing on the pad that is plugged in.
Each family keeps its own state file. The first time a pad with a
known USB id turns up it is drawn with the layout we know for it,
so a 9 key pad is never shown as 12 keys and 2 knobs. Returns True
when the state was switched to another pad.
"""
dev = self.diag.device if self.diag else None
if not dev:
self.inspector.backend = None
self._offer_lighting(None)
return False
self.inspector.backend = core.backend_for(dev)
self._offer_lighting(self.inspector.backend)
ident = f"{dev.vid}:{dev.pid}"
if ident == self.state.device_id:
return False
state = core.State(device_id=ident)
if not state.path.exists():
known = core.KNOWN_LAYOUTS.get((dev.vid, dev.pid))
if known:
state.layout = known
self.state = state
self.desired.clear()
self.pending_actions.clear()
self.pad.set_layout(self.state.layout) # drops a stale selection
# The upright view suits a pad that stands on its edge, which the
# knob pads do. A knobless pad lies flat, so draw it that way until
# the owner says otherwise.
if not self.state.layout.knobs and self.settings.value("orientation") is None:
self.pad.set_orientation("flat")
return True
# ------------------------------------------------------------- write # ------------------------------------------------------------- write
def _write(self, controls): def _write(self, controls):
@@ -1278,6 +1339,7 @@ class Window(QMainWindow):
else: else:
n = len(got) n = len(got)
self._say(f"Read {n} control{'s' if n != 1 else ''} from the pad.", GOOD) self._say(f"Read {n} control{'s' if n != 1 else ''} from the pad.", GOOD)
self._sync_lighting_from_pad()
finally: finally:
self.read_btn.setEnabled(True) self.read_btn.setEnabled(True)
self.read_btn.setText("Read pad") self.read_btn.setText("Read pad")
@@ -1339,7 +1401,7 @@ class Window(QMainWindow):
return return
layout = found.layout layout = found.layout
self.state = core.State(layout=layout) self.state = core.State(layout=layout, device_id=f"{dev.vid}:{dev.pid}")
self.state.layout = layout self.state.layout = layout
for control, binding in found.bindings.items(): for control, binding in found.bindings.items():
self.state.record(control, binding) self.state.record(control, binding)
@@ -1360,6 +1422,85 @@ class Window(QMainWindow):
self.message.setText(text) self.message.setText(text)
self.message.setStyleSheet(f"color: {colour};" if colour else "") self.message.setStyleSheet(f"color: {colour};" if colour else "")
# ---------------------------------------------------------- lighting
def _offer_lighting(self, backend):
"""Show the Lighting menu for a pad that has modes, hide it otherwise."""
modes = getattr(backend, "lighting_modes", None)
if not modes:
self.light_btn.hide()
self.light_backend = None
return
if backend is not self.light_backend:
self.light_backend = backend
self.light_menu.clear()
for a in list(self.light_group.actions()):
self.light_group.removeAction(a)
self.light_actions = {}
custom = getattr(backend, "lighting_custom", None)
for i, name in enumerate(modes):
if i == custom:
continue
a = self.light_menu.addAction(name.capitalize())
a.setCheckable(True)
a.setData(i)
self.light_group.addAction(a)
self.light_actions[i] = a
self.light_custom = None
if custom is not None:
self.light_menu.addSeparator()
self.light_custom = self.light_menu.addAction("Custom colour…")
self.light_custom.setCheckable(True)
self.light_custom.setData(custom)
self.light_group.addAction(self.light_custom)
self.light_actions[custom] = self.light_custom
self._sync_lighting_from_pad()
self.light_btn.show()
def _sync_lighting_from_pad(self):
"""Tick the mode the pad reported in its last read, if it did."""
backend, dev = self.light_backend, self.diag.device if self.diag else None
if backend is None or dev is None or not hasattr(backend, "last_lighting"):
return
got = backend.last_lighting(dev)
if got:
self._sync_light_menu(*got)
def _sync_light_menu(self, mode, colour):
a = self.light_actions.get(mode)
if a is not None:
a.setChecked(True)
if colour is not None:
self.light_colour = tuple(colour)
if self.light_custom is not None:
hexcol = "#%02x%02x%02x" % self.light_colour if self.light_colour else ""
self.light_custom.setText(f"Custom colour… {hexcol}".rstrip())
def _set_lighting(self, mode, colour=None):
"""Send a lighting mode to the pad; the custom one asks for a colour."""
backend, dev = self.light_backend, self.diag.device if self.diag else None
if backend is None or dev is None or not (self.diag and self.diag.ready):
self._say(self.diag.headline if self.diag else "No pad.", BAD)
self._sync_lighting_from_pad()
return
if mode == getattr(backend, "lighting_custom", None) and colour is None:
start = QColor(*self.light_colour) if self.light_colour else QColor("white")
picked = QColorDialog.getColor(start, self, "Custom colour")
if not picked.isValid():
self._sync_lighting_from_pad()
return
colour = (picked.red(), picked.green(), picked.blue())
try:
backend.set_lighting(dev, mode, colour)
except (OSError, ValueError) as e:
self._say(f"Couldn't set the lighting: {core._explain(e) if isinstance(e, OSError) else e}", BAD)
self._sync_lighting_from_pad()
return
self._sync_light_menu(mode, colour)
name = backend.lighting_modes[mode]
extra = " #%02x%02x%02x" % tuple(colour) if colour else ""
self._say(f"Lighting: {name}{extra}.", GOOD)
# ------------------------------------------------------------- misc # ------------------------------------------------------------- misc
def _edit_layout(self, on, keep=False): def _edit_layout(self, on, keep=False):

View File

@@ -8,9 +8,10 @@ base.py and listing the class here.
""" """
from .ch57x import CH57x from .ch57x import CH57x
from .itprotech import Itprotech
from .sayo import Sayo from .sayo import Sayo
REGISTRY = (CH57x(), Sayo()) # tried in order; first to claim a device wins REGISTRY = (CH57x(), Sayo(), Itprotech()) # tried in order; first to claim a device wins
DEFAULT = REGISTRY[0] DEFAULT = REGISTRY[0]

View File

@@ -15,6 +15,7 @@ class Backend:
layers = 1 layers = 1
max_keys = 0 max_keys = 0
max_knobs = 0 max_knobs = 0
sequences = False # can one control send several keys in a row?
def claims(self, vid, pid): def claims(self, vid, pid):
"""True only for ids this backend has been confirmed on.""" """True only for ids this backend has been confirmed on."""
@@ -40,6 +41,13 @@ class Backend:
def supports(self, keys, knobs): def supports(self, keys, knobs):
return 1 <= keys <= self.max_keys and 0 <= knobs <= self.max_knobs return 1 <= keys <= self.max_keys and 0 <= knobs <= self.max_knobs
def check(self, binding):
"""
Why this family can't hold a binding the GUI thinks is valid, or
None. Lets the editor say so while typing instead of at write time.
"""
return None
# The six a family must answer. # The six a family must answer.
def action_byte(self, control): def action_byte(self, control):

View File

@@ -35,6 +35,11 @@ class CH57x(Backend):
max_keys = proto.KEY_SLOTS max_keys = proto.KEY_SLOTS
max_knobs = proto.KNOB_SLOTS max_knobs = proto.KNOB_SLOTS
@property
def sequences(self):
from .. import core
return core.SEQUENCES_VERIFIED
# ------------------------------------------------------------ protocol # ------------------------------------------------------------ protocol
def action_byte(self, control): def action_byte(self, control):

View File

@@ -0,0 +1,213 @@
"""
ITPROTECH MK series backend (USB vendor id 8808). Confirmed on the MK-09,
the 9 key pad, by reading and writing it; the wire format and the reasons
behind it are in itprotech_proto.py.
Three things about this firmware shape the code:
* One read command returns everything: every key's record, the lighting
mode and the custom colour. There are no layers and no knobs, and a
key is addressed by its number, 1 upwards, left to right, top to bottom.
* It acts on every other output report. Each report goes out twice, and
the pad does it exactly once (see REPEAT in the proto module).
* It can be read back, so a write is checked: after sending, the key is
read again and has to come back byte for byte as sent. A write that
doesn't is reported as such, and the control is marked unknown.
Lighting is pad wide rather than per key, so the GUI offers it in a
Lighting menu at the foot of the window, and the CLI as a subcommand:
macropad lighting show
macropad lighting rgb "press: blue"
macropad lighting colour ff8800
"""
import errno
import time
from . import itprotech_proto as wire
from .base import Backend
class Itprotech(Backend):
key = "itprotech"
name = "ITPROTECH MK series"
vid = wire.VID
pids = wire.CONFIRMED # 660a; siblings are offered detection first
layers = 1
max_keys = 12
max_knobs = 0
sequences = True # multikey: several keys, one modifier mask
lighting_modes = wire.RGB_MODES # offered by the GUI's Lighting menu
lighting_custom = wire.RGB_CUSTOM # the mode that takes a colour
def __init__(self):
self._data_len = {} # device path -> key record length seen
self._lighting = {} # device path -> (mode, colour) last read
# ------------------------------------------------------------ protocol
def action_byte(self, control):
"""Keys are their own number, from 1. There are no knobs."""
if control.startswith("key"):
return int(control[3:])
raise ValueError(f"{self.name} pads have no knobs: {control}")
def check(self, binding):
try:
wire.encode(binding, self._data_len_guess())
except ValueError as e:
return str(e)
return None
def _data_len_guess(self, device=None):
if device is not None and device.path in self._data_len:
return self._data_len[device.path]
if device is not None and device.pid in wire.DATA_LEN:
return wire.DATA_LEN[device.pid]
return wire.DATA_LEN[wire.PID_MK09]
# ---------------------------------------------------------------- read
def _dump(self, device):
"""Everything the pad holds, parsed. Raises core.ReadError."""
from .. import core
replies = core._exchange(device.path, wire.read_request(),
repeat=wire.REPEAT)
dump = wire.parse_dump(replies)
if not dump["keys"]:
# One more go: a stray report from earlier can upset the pad's
# every-other-one rhythm, and a second pair puts it right.
replies = core._exchange(device.path, wire.read_request(),
repeat=wire.REPEAT)
dump = wire.parse_dump(replies)
if dump["data_len"]:
self._data_len[device.path] = dump["data_len"]
if dump["rgb"] is not None:
self._lighting[device.path] = (dump["rgb"], dump["colour"])
return dump
def info(self, device):
"""(keys, 0) as the pad reports them, or None if it says nothing."""
dump = self._dump(device)
if wire.consistent(dump):
return None
return len(dump["keys"]), 0
def _bindings(self, dump):
from .. import core
out = {}
for index, report in sorted(dump["keys"].items()):
got = wire.decode(report)
if got and got[1] is not None:
out[f"key{index}"] = got[1]
return out
def read_layer(self, device, layer=1, layout=None):
return self._bindings(self._dump(device))
def lighting(self, device):
"""(mode, (r, g, b)) as stored on the pad."""
dump = self._dump(device)
return dump["rgb"], dump["colour"]
def last_lighting(self, device):
"""
(mode, colour) from the most recent read of this pad, or None.
Every read returns the lighting along with the keys, so the GUI can
show it without asking the pad again.
"""
return self._lighting.get(device.path)
# --------------------------------------------------------------- write
def _send(self, device, report, _write=None):
"""One report, sent the way this firmware needs it: twice."""
import macropad as proto
write = _write or proto.write_report
for _ in range(wire.REPEAT):
write(device.path, report)
time.sleep(wire.WRITE_GAP)
def write(self, device, control, binding, state, layer=1, _write=None):
from .. import core
index = self.action_byte(control)
data_len = self._data_len_guess(device)
try:
kind, body = wire.encode(binding, data_len)
report = wire.key_frame(data_len, index, kind, body)
except ValueError as e:
raise core.WriteError(f"{control}: {e}", pad_touched=False)
try:
self._send(device, report, _write)
except OSError as e:
why = core._explain(e)
untouched = e.errno in (errno.ENOENT, errno.ENODEV,
errno.EACCES, errno.EPERM)
if not untouched:
state.mark_unknown(control, f"write interrupted: {why}", layer)
state.save()
raise core.WriteError(f"{control}: {why}",
pad_touched=not untouched, cause=e)
# The pad can be read, so check the write landed before believing it.
try:
dump = self._dump(device)
except core.ReadError as e:
state.mark_unknown(control, f"written, but couldn't read back: {e}",
layer)
state.save()
raise core.WriteError(f"{control}: sent, but the pad didn't answer "
f"when asked to confirm ({e})",
pad_touched=True, cause=e)
got = dump["keys"].get(index, b"")
want = report[1:data_len + 2] # len byte + record
if got[:data_len + 1] != want:
state.mark_unknown(control, "the pad holds something other than "
"what was written", layer)
state.save()
raise core.WriteError(f"{control}: the pad didn't take the write "
f"(it holds {got[:6].hex(' ') or 'nothing'})",
pad_touched=True)
state.record(control, binding, layer)
state.save()
def set_lighting(self, device, mode, colour=None, _write=None):
"""Lighting mode 0..14, and the custom colour when mode is 14."""
self._send(device, wire.rgb_frame(mode), _write)
if mode == wire.RGB_CUSTOM and colour is not None:
self._send(device, wire.colour_frame(*colour), _write)
old = self._lighting.get(device.path, (None, None))
self._lighting[device.path] = (mode, colour if colour is not None else old[1])
# -------------------------------------------------------------- detect
def detect(self, device):
"""
Ask an unknown 8808 pad what it is, reading only.
The read command is the only query there is, so the test is whether
its answer looks like one pad: key records 1..n that agree on their
length, plus a lighting reply. A pad that speaks something else sends
nothing we recognise and is left alone.
"""
from .. import core
try:
dump = self._dump(device)
except core.ReadError as e:
return core.Detection(why=f"it didn't answer: {e}")
why = wire.consistent(dump)
if why:
return core.Detection(why=why)
n = len(dump["keys"])
if not self.supports(n, 0):
return core.Detection(why=f"it reported {n} keys, more than this "
"protocol is known to cover")
model = wire.MODELS.get(device.pid, (None, None, f"{n} keys"))[2]
return core.Detection(speaks=True, keys=n, knobs=0, layers=1,
bindings=self._bindings(dump),
model=f"ITPROTECH {model}")

View File

@@ -0,0 +1,286 @@
"""
Wire format of the ITPROTECH "MK" macro pads (USB vendor id 8808).
Sold in Japan as ちょい足しキーボード (MK-02, MK-04, MK-09 ...), and the same
hardware turns up unbranded elsewhere. The vendor's Windows configurator is a
.NET program (Run-me.exe, assembly yywlKey, namespace HID_SIMPLE); everything
here was read out of that program and then checked against an MK-09
(8808:660a) on a desk. Nothing is guessed from captures alone.
The config channel is the pad's fourth HID interface: usage page 0xFF00,
64 byte input and output reports, no report id. Every report, both ways, is
len payload... padded with zeros to 64 bytes
where len counts the payload bytes.
Commands (host to pad):
01 55 read: the pad answers with one report per key
(below), then 02 63 <rgb mode>, then
04 64 <g> <r> <b> for the custom colour
0d <key> <type> ... bind one key; 0x0d is the per-model payload
length (13 on the 9 key pad, see DATA_LEN)
02 57 <mode> lighting mode 0..14, see RGB_MODES
04 70 <g> <r> <b> custom colour for mode 14. Green first. Really.
01 56 enter the firmware bootloader. NEVER SEND THIS.
Key types:
01 keyboard <mods> <keycode>
02 mouse <buttons> <x> <y> <wheel> (not offered here)
03 media 04 <usage lo> <usage hi> 00 consumer page usage
04 multikey <mods> <keycode> ... up to len-3 several keys, one
modifier mask
ff empty a slot the vendor software never wrote
One oddity shapes everything: the firmware acts on every OTHER output
report it receives. Send a command once and it may or may not happen;
send it twice, twenty milliseconds apart, and it happens exactly once.
The vendor software sends everything twice. So does this module.
"""
import macropad as proto
VID = "8808"
PID_MK09 = "660a"
CONFIRMED = (PID_MK09,)
REPORT_LEN = 65 # report id 0 + 64 payload bytes
REPEAT = 2 # every report twice; the pad acts on every other one
WRITE_GAP = 0.02 # the vendor's pause between the two copies
CMD_READ = 0x55
CMD_BOOTLOADER = 0x56 # refused by frame(); documented so nobody tries
CMD_RGB = 0x57
CMD_COLOUR = 0x70
REPLY_RGB = 0x63 # 02 63 <mode>
REPLY_COLOUR = 0x64 # 04 64 <g> <r> <b>
TYPE_NONE = 0x00
TYPE_KEY = 0x01
TYPE_MOUSE = 0x02
TYPE_MEDIA = 0x03
TYPE_MULTI = 0x04
TYPE_EMPTY = 0xFF
MEDIA_TAG = 0x04 # first body byte of a media binding
# Plain N key members of the family and the payload length each one uses,
# as the vendor software has them. Only 660a has been seen; the rest are
# here so detect() can name a sibling, and it still reads the real length
# off the pad before writing anything.
MODELS = {
"6600": (1, 0x35, "1 key"),
"6601": (2, 0x35, "2 keys"),
"6602": (2, 0x35, "2 keys (mini)"),
"6603": (3, 0x12, "3 keys"),
"6604": (4, 0x12, "4 keys"),
"6605": (5, 0x12, "5 keys"),
"6606": (6, 0x12, "6 keys"),
"6608": (7, 0x11, "7 keys"),
"6609": (8, 0x0F, "8 keys"),
PID_MK09: (9, 0x0D, "MK-09, 9 keys"),
"660b": (12, 0x0A, "12 keys"),
"6612": (10, 0x0A, "DIY 10 keys"),
"6617": (8, 0x0C, "8 keys (new)"),
}
DATA_LEN = {pid: m[1] for pid, m in MODELS.items()}
RGB_MODES = [
"off", "flow 1", "press: red", "press: green", "press: blue",
"press: yellow", "press: cyan", "press: purple", "press: white",
"flow 2", "flow 3", "irradiation", "colourful gradient", "table lamp",
"custom colour",
]
RGB_CUSTOM = RGB_MODES.index("custom colour")
# Consumer usages the vendor software offers, so a key set there reads back
# with a name. Standard HID consumer page codes; the pad passes them through.
VENDOR_MEDIA = {
0x00CD: "playpause", 0x00B7: "stop", 0x00B6: "prev", 0x00B5: "next",
0x0194: "mycomputer", 0x006F: "brightnessup", 0x0070: "brightnessdown",
0x0183: "mediaselect", 0x00E2: "mute", 0x00EA: "volumedown",
0x00E9: "volumeup", 0x0192: "calculator", 0x0223: "wwwhome",
0x018A: "mail", 0x0153: "bassdown", 0x0152: "bassup",
0x0155: "trebledown", 0x0154: "trebleup", 0x0227: "refresh",
0x0225: "forward", 0x0224: "back",
}
def media_name(usage):
"""The CLI name for a consumer usage, or None if nobody has a word for it."""
for name, code in proto.MEDIA_KEYS.items():
if code == usage:
return name
return VENDOR_MEDIA.get(usage)
# ------------------------------------------------------------------ frames
def frame(payload):
"""One 65 byte report: report id 0, length byte, payload, zero padding."""
payload = bytes(payload)
if not payload or payload[0] == CMD_BOOTLOADER and len(payload) == 1:
raise ValueError("refusing to send the bootloader command")
if len(payload) > REPORT_LEN - 2:
raise ValueError(f"payload of {len(payload)} bytes doesn't fit a report")
body = bytes([len(payload)]) + payload
return bytes([0]) + body + bytes(REPORT_LEN - 1 - len(body))
def read_request():
return frame([CMD_READ])
def rgb_frame(mode):
if not 0 <= mode < len(RGB_MODES):
raise ValueError(f"lighting mode must be 0-{len(RGB_MODES) - 1}")
return frame([CMD_RGB, mode])
def colour_frame(r, g, b):
for v in (r, g, b):
if not 0 <= v <= 255:
raise ValueError("colour components are 0-255")
return frame([CMD_COLOUR, g, r, b]) # the pad wants green first
def key_frame(data_len, index, kind, body):
"""The report that binds one key. body is everything after the type."""
if not 1 <= index <= 0xFE:
raise ValueError(f"key index {index} out of range")
payload = bytes([index, kind]) + bytes(body)
if len(payload) > data_len:
raise ValueError(f"{len(payload)} bytes won't fit this pad's "
f"{data_len} byte key record")
return frame(payload + bytes(data_len - len(payload)))
# ------------------------------------------------------------------ encode
def parse_keys(text):
"""
'ctrl+c' -> (mods, [keycode]); 'a,b,c' -> (0, [a, b, c]).
Goes through macropad.MODIFIERS and macropad.KEYCODES like the CLI, so a
binding typed once means the same on every family of pad. A sequence
needs the same modifiers on every step, because that is all the pad can
store: one modifier byte, then up to ten keycodes.
"""
steps = proto.parse_keystroke(text)
mods = {m for m, _ in steps}
if len(mods) > 1:
raise ValueError("this pad holds one set of modifiers for the whole "
"sequence, so every step must use the same ones")
codes = [c for _, c in steps]
if len(codes) > 1 and 0 in codes:
raise ValueError("every step of a sequence needs a key, not just "
"modifiers")
return steps[0][0], codes
def encode(binding, data_len):
"""
(type, body) for one Binding, or ValueError saying why the pad can't
hold it. Nothing here touches hardware.
"""
if binding.none:
return TYPE_KEY, bytes([0, 0])
if binding.media:
usage = proto.MEDIA_KEYS.get(binding.media)
if usage is None:
raise ValueError(f"don't know the media key {binding.media!r}")
return TYPE_MEDIA, bytes([MEDIA_TAG, usage & 0xFF, usage >> 8, 0])
if binding.delay:
raise ValueError("this pad has no delay between keys")
mods, codes = parse_keys(binding.keys)
if len(codes) == 1:
return TYPE_KEY, bytes([mods, codes[0]])
room = data_len - 3
if len(codes) > room:
raise ValueError(f"this pad holds at most {room} keys in a sequence")
return TYPE_MULTI, bytes([mods]) + bytes(codes)
# ------------------------------------------------------------------ decode
def _names(mods, code, core):
names = [n for n, bit in core._MOD_ORDER if mods & bit]
if code:
name = core._KEYNAME_BY_CODE.get(code)
if name is None:
return None
names.append(name)
return "+".join(names)
def decode(report):
"""
(index, Binding or None) from one key report, or None if the report
isn't a key record at all. None for the binding means "a key we can see
but can't name": a mouse action, or a slot the vendor never wrote.
"""
from .. import core
if len(report) < 4 or report[0] < 4:
return None
data_len = report[0]
index, kind = report[1], report[2]
body = report[3:data_len + 1]
if kind in (TYPE_NONE,) or (kind == TYPE_KEY and body[0] == 0 and body[1] == 0):
return index, core.Binding(none=True)
if kind == TYPE_KEY:
text = _names(body[0], body[1], core)
return index, core.Binding(keys=text) if text else None
if kind == TYPE_MEDIA and body[0] == MEDIA_TAG:
name = media_name(body[1] | (body[2] << 8))
return index, core.Binding(media=name) if name else None
if kind == TYPE_MULTI:
mods = body[0]
codes = [c for c in body[1:] if c not in (0, TYPE_EMPTY)]
steps = [_names(mods, c, core) for c in codes]
if not steps or None in steps:
return index, None
return index, core.Binding(keys=",".join(steps))
return index, None
def is_key_report(report):
return (len(report) >= 4 and report[0] >= 4
and report[0] not in (2, 4) and 1 <= report[1] <= 0xFE)
def parse_dump(replies):
"""
What a read returned, sorted out: {"keys": {index: report},
"data_len": int or None, "rgb": mode or None, "colour": (r, g, b) or None}.
The rgb and colour replies carry stale bytes after the ones that matter,
so only those are read.
"""
out = {"keys": {}, "data_len": None, "rgb": None, "colour": None}
for r in replies:
if len(r) < 3:
continue
if r[0] == 2 and r[1] == REPLY_RGB:
out["rgb"] = r[2]
elif r[0] == 4 and r[1] == REPLY_COLOUR and len(r) >= 5:
out["colour"] = (r[3], r[2], r[4])
elif is_key_report(r):
out["keys"][r[1]] = bytes(r)
out["data_len"] = r[0]
return out
def consistent(dump):
"""Why a dump isn't a clean picture of one pad, or '' if it is."""
keys = dump["keys"]
if not keys:
return "it sent no key records"
lengths = {r[0] for r in keys.values()}
if len(lengths) != 1:
return "its key records disagree about their own length"
n = len(keys)
if set(keys) != set(range(1, n + 1)):
return f"it reported keys {sorted(keys)}, not 1 to {n}"
return ""

View File

@@ -211,6 +211,7 @@ class Layout:
# covers several different pads. # covers several different pads.
KNOWN_LAYOUTS = { KNOWN_LAYOUTS = {
("1189", "8840"): Layout(12, 2, 3, 4, "12 keys, 2 knobs"), ("1189", "8840"): Layout(12, 2, 3, 4, "12 keys, 2 knobs"),
("8808", "660a"): Layout(9, 0, 3, 3, "ITPROTECH MK-09, 9 keys"),
} }
DEFAULT_LAYOUT = KNOWN_LAYOUTS[(VID, PID)] DEFAULT_LAYOUT = KNOWN_LAYOUTS[(VID, PID)]
@@ -569,7 +570,8 @@ def _matching_rules():
s = line.strip() s = line.strip()
if s.startswith("#"): if s.startswith("#"):
continue continue
if re.search(rf'idVendor\}}=="{VID}"', s, re.I) and "uaccess" in s: if "uaccess" in s and any(
re.search(rf'idVendor\}}=="{v}"', s, re.I) for v in vendors()):
hits.append(f) hits.append(f)
break break
return hits return hits
@@ -698,7 +700,9 @@ def diagnose(keyd=True):
"macropad-probe.py and check the interface list."], "macropad-probe.py and check the interface list."],
warnings=warnings) warnings=warnings)
return Diagnosis("unplugged", "No pad connected.", return Diagnosis("unplugged", "No pad connected.",
detail=[f"Looking for {DEVICE_ID} on USB."], detail=["Looking for " + ", ".join(
f"{v}:{'/'.join(p) or '*'}" for v, p, _ in backends().known_ids())
+ " on USB."],
warnings=warnings) warnings=warnings)
d = found[0] d = found[0]
@@ -862,10 +866,13 @@ class ReadError(Exception):
pass pass
def _exchange(path, request, want=None): def _exchange(path, request, want=None, repeat=1):
""" """
Send one request and collect replies until the pad goes quiet. Send one request and collect replies until the pad goes quiet.
repeat sends the same request that many times, WRITE_GAP apart, for
firmware that acts on every other report (the ITPROTECH pads).
Reading is the one place we write to the pad without changing it: both Reading is the one place we write to the pad without changing it: both
commands here are queries, byte for byte what the vendor software sends. commands here are queries, byte for byte what the vendor software sends.
""" """
@@ -884,6 +891,9 @@ def _exchange(path, request, want=None):
except OSError: except OSError:
break break
try: try:
for i in range(repeat):
if i:
time.sleep(WRITE_GAP)
os.write(fd, request) os.write(fd, request)
except OSError as e: except OSError as e:
raise ReadError(_explain(e)) from e raise ReadError(_explain(e)) from e

View File

@@ -1,3 +1,5 @@
# MacroPad: let the logged-in user configure 1189:8840 macro pads. # MacroPad: let the logged-in user configure the macro pads it supports.
# Must sort before 73-seat-late.rules, which is where uaccess takes effect. # Must sort before 73-seat-late.rules, which is where uaccess takes effect.
KERNEL=="hidraw*", ATTRS{idVendor}=="1189", TAG+="uaccess" KERNEL=="hidraw*", ATTRS{idVendor}=="1189", TAG+="uaccess"
KERNEL=="hidraw*", ATTRS{idVendor}=="8089", TAG+="uaccess"
KERNEL=="hidraw*", ATTRS{idVendor}=="8808", TAG+="uaccess"

View File

@@ -140,11 +140,11 @@ class Routing(unittest.TestCase):
self.assertEqual(ch.max_keys, proto.KEY_SLOTS) self.assertEqual(ch.max_keys, proto.KEY_SLOTS)
def test_discovery_covers_every_vendor(self): def test_discovery_covers_every_vendor(self):
self.assertEqual(core.vendors(), {"1189", "8089", "c0de"}) self.assertEqual(core.vendors(), {"1189", "8089", "8808", "c0de"})
with mock.patch.object(proto, "find_devices", return_value=[]) as f: with mock.patch.object(proto, "find_devices", return_value=[]) as f:
core.discover() core.discover()
f.assert_called_once() f.assert_called_once()
self.assertEqual(f.call_args[0][0], {"1189", "8089", "c0de"}) self.assertEqual(f.call_args[0][0], {"1189", "8089", "8808", "c0de"})
def test_one_udev_rule_per_vendor(self): def test_one_udev_rule_per_vendor(self):
lines = core.rule_lines() lines = core.rule_lines()

View File

@@ -900,8 +900,13 @@ class GuiSmoke(unittest.TestCase):
self.skipTest("PySide6 not installed") self.skipTest("PySide6 not installed")
from unittest import mock from unittest import mock
from macropad_gui import actions as act_mod from macropad_gui import actions as act_mod
# Whatever is plugged in while the tests run, the window under test
# must see no pad, or it follows that pad's layout and the
# assertions below are about the wrong shape.
with tempfile.TemporaryDirectory() as d, \ with tempfile.TemporaryDirectory() as d, \
mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": d}): mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": d}), \
mock.patch.object(core, "diagnose", return_value=core.Diagnosis(
"unplugged", "No pad connected.")):
qa = QApplication.instance() or QApplication([]) qa = QApplication.instance() or QApplication([])
w = app.Window() w = app.Window()
w.timer.stop() w.timer.stop()

415
tests/test_itprotech.py Normal file
View File

@@ -0,0 +1,415 @@
"""
The ITPROTECH backend, against what a real MK-09 said.
REAL_DUMP is the pad's answer to the read command on 2026-10-23, fresh out
of the box: keys 1 to 9 bound to a s d f g h j k l, lighting mode 12,
custom colour white. WRITES are reports that were sent to the same pad and
read back byte for byte. A fake pad answers out of those, so the backend
is held to what the hardware actually did, not to what the vendor's code
appears to do.
"""
import tempfile
import unittest
from pathlib import Path
from unittest import mock
import macropad as proto
from macropad_gui import backends, core
from macropad_gui.backends import itprotech_proto as wire
from macropad_gui.backends.itprotech import Itprotech
# One 64 byte input report per line, as /dev/hidraw delivered them. The
# bytes after the record are whatever the firmware had lying around.
REAL_DUMP = [bytes.fromhex(h) + bytes(64 - 16) for h in (
"0d 01 01 00 04 00 00 00 00 00 00 00 00 00 00 fd",
"0d 02 01 00 16 00 00 00 00 00 00 00 00 00 00 fd",
"0d 03 01 00 07 00 00 00 00 00 00 00 00 00 00 fd",
"0d 04 01 00 09 00 00 00 00 00 00 00 00 00 00 fd",
"0d 05 01 00 0a 00 00 00 00 00 00 00 00 00 00 fd",
"0d 06 01 00 0b 00 00 00 00 00 00 00 00 00 00 fd",
"0d 07 01 00 0d 00 00 00 00 00 00 00 00 00 00 fd",
"0d 08 01 00 0e 00 00 00 00 00 00 00 00 00 00 fd",
"0d 09 01 00 0f 00 00 00 00 00 00 00 00 00 00 fd",
"02 63 0c 00 0f 00 00 00 00 00 00 00 00 00 00 fd",
"04 64 ff ff ff 00 00 00 00 00 00 00 00 00 00 fd",
)]
# (what was written, what key 9 then read back as)
WRITES = [
("00 0d 09 01 00 10", "0d 09 01 00 10 00 00 00 00 00 00 00 00 00"), # m
("00 0d 09 03 04 e9 00 00", "0d 09 03 04 e9 00 00 00 00 00 00 00 00 00"), # vol+
("00 0d 09 04 00 0b 08 0f 0f 12", "0d 09 04 00 0b 08 0f 0f 12 00 00 00 00 00"), # hello
("00 0d 09 01 00 0f", "0d 09 01 00 0f 00 00 00 00 00 00 00 00 00"), # l again
]
def fake_device(pid="660a", path="/dev/hidraw-mk09"):
return core.Device(path=path, report_id=0, writable=True, vid="8808", pid=pid)
class FakePad:
"""
Behaves like the MK-09 on the desk: answers the read command with the
dump, acts on every OTHER output report, and stores key records.
"""
def __init__(self, replies=None):
self.records = {}
for r in (replies or REAL_DUMP):
if wire.is_key_report(r):
self.records[r[1]] = bytes(r)
self.other = [r for r in (replies or REAL_DUMP) if not wire.is_key_report(r)]
self.seen = 0 # output reports received
self.acted = [] # the ones the firmware actually processed
self.written = [] # every report, as the host sent it
def _receive(self, report):
self.seen += 1
self.written.append(bytes(report))
if self.seen % 2: # the every-other-one rule
return []
self.acted.append(bytes(report))
length, payload = report[1], report[2:]
if length == 1 and payload[0] == wire.CMD_READ:
return [self.records[i] for i in sorted(self.records)] + self.other
if length >= 4 and payload[0] not in (wire.CMD_RGB, wire.CMD_COLOUR):
record = bytes([length]) + payload[:length]
self.records[record[1]] = record + bytes(64 - len(record))
return []
def exchange(self, path, request, want=None, repeat=1):
out = []
for _ in range(repeat):
out += self._receive(request)
return out
def write_report(self, path, report):
self._receive(report)
class Frames(unittest.TestCase):
"""The bytes, before any hardware is involved."""
def test_read_request_is_what_the_vendor_sends(self):
self.assertEqual(wire.read_request()[:3], bytes.fromhex("00 01 55"))
self.assertEqual(len(wire.read_request()), 65)
def test_key_writes_match_the_pad(self):
for sent, _ in WRITES:
want = bytes.fromhex(sent)
record = want[1:]
kind, body = record[2], record[3:]
got = wire.key_frame(0x0d, record[1], kind, body)
self.assertEqual(got[:len(want)], want)
self.assertEqual(got[len(want):], bytes(65 - len(want)))
def test_lighting_frames(self):
self.assertEqual(wire.rgb_frame(12)[:4], bytes.fromhex("00 02 57 0c"))
# green first, as the vendor software sends it
self.assertEqual(wire.colour_frame(0x11, 0x22, 0x33)[:6],
bytes.fromhex("00 04 70 22 11 33"))
def test_the_bootloader_command_cannot_be_framed(self):
with self.assertRaises(ValueError):
wire.frame([wire.CMD_BOOTLOADER])
def test_a_record_that_does_not_fit_is_refused(self):
with self.assertRaises(ValueError):
wire.key_frame(0x0d, 1, wire.TYPE_MULTI, bytes(12))
class Encoding(unittest.TestCase):
def enc(self, **kw):
return wire.encode(core.Binding(**kw).validate(), 0x0d)
def test_shortcut(self):
self.assertEqual(self.enc(keys="ctrl+c"), (wire.TYPE_KEY, bytes([0x01, 0x06])))
def test_sequence_shares_one_modifier_byte(self):
self.assertEqual(self.enc(keys="h,e,l,l,o"),
(wire.TYPE_MULTI, bytes([0, 0x0b, 0x08, 0x0f, 0x0f, 0x12])))
self.assertEqual(self.enc(keys="ctrl+a,ctrl+c"),
(wire.TYPE_MULTI, bytes([0x01, 0x04, 0x06])))
def test_sequence_with_mixed_modifiers_is_refused(self):
with self.assertRaises(ValueError):
self.enc(keys="ctrl+a,c")
def test_sequence_longer_than_the_record_is_refused(self):
with self.assertRaises(ValueError):
self.enc(keys=",".join("abcdefghijk")) # 11 keys, room for 10
self.assertEqual(self.enc(keys=",".join("abcdefghij"))[0], wire.TYPE_MULTI)
def test_delay_is_refused(self):
with self.assertRaises(ValueError):
self.enc(keys="a,b", delay=50)
def test_media(self):
self.assertEqual(self.enc(media="volumeup"),
(wire.TYPE_MEDIA, bytes([0x04, 0xe9, 0x00, 0x00])))
self.assertEqual(self.enc(media="wwwhome"),
(wire.TYPE_MEDIA, bytes([0x04, 0x23, 0x02, 0x00])))
def test_nothing_is_a_keyboard_binding_with_no_key(self):
self.assertEqual(self.enc(none=True), (wire.TYPE_KEY, bytes([0, 0])))
def test_every_vendor_media_key_has_a_name_the_cli_accepts(self):
for usage, name in wire.VENDOR_MEDIA.items():
self.assertIn(name, proto.MEDIA_KEYS, name)
self.assertEqual(proto.MEDIA_KEYS[name], usage, name)
class Decoding(unittest.TestCase):
def test_the_factory_bindings(self):
got = {wire.decode(r)[0]: wire.decode(r)[1] for r in REAL_DUMP[:9]}
self.assertEqual([got[i].keys for i in range(1, 10)], list("asdfghjkl"))
def test_round_trips_of_what_was_written(self):
for sent, back in WRITES:
index, binding = wire.decode(bytes.fromhex(back))
self.assertEqual(index, 9)
kind, body = wire.encode(binding, 0x0d)
self.assertEqual(wire.key_frame(0x0d, 9, kind, body)[:len(bytes.fromhex(sent))],
bytes.fromhex(sent), binding)
def test_labels(self):
self.assertEqual(wire.decode(bytes.fromhex(WRITES[1][1]))[1].label(), "Volume up")
self.assertEqual(wire.decode(bytes.fromhex(WRITES[2][1]))[1].keys, "h,e,l,l,o")
def test_a_mouse_key_is_seen_but_not_named(self):
index, binding = wire.decode(bytes.fromhex("0d 05 02 01 00 00 00") + bytes(7))
self.assertEqual((index, binding), (5, None))
def test_an_empty_slot_is_seen_but_not_named(self):
self.assertEqual(wire.decode(bytes.fromhex("0d 05 ff") + bytes(11)), (5, None))
def test_dump_parsing(self):
dump = wire.parse_dump(REAL_DUMP)
self.assertEqual(sorted(dump["keys"]), list(range(1, 10)))
self.assertEqual(dump["data_len"], 0x0d)
self.assertEqual(dump["rgb"], 12)
self.assertEqual(dump["colour"], (255, 255, 255))
self.assertEqual(wire.consistent(dump), "")
class OnThePad(unittest.TestCase):
def setUp(self):
self.pad = FakePad()
self.backend = Itprotech()
patch = mock.patch.object(core, "_exchange", self.pad.exchange)
patch.start()
self.addCleanup(patch.stop)
self.dir = tempfile.TemporaryDirectory()
self.addCleanup(self.dir.cleanup)
self.state = core.State(Path(self.dir.name) / "s.json",
layout=core.KNOWN_LAYOUTS[("8808", "660a")],
device_id="8808:660a")
def test_info_counts_the_keys(self):
self.assertEqual(self.backend.info(fake_device()), (9, 0))
def test_a_silent_pad_gives_none(self):
self.pad.records.clear()
self.pad.other.clear()
self.assertIsNone(self.backend.info(fake_device()))
def test_read_layer(self):
got = self.backend.read_layer(fake_device())
self.assertEqual(got["key1"].keys, "a")
self.assertEqual(got["key9"].keys, "l")
self.assertEqual(len(got), 9)
def test_lighting(self):
self.assertEqual(self.backend.lighting(fake_device()), (12, (255, 255, 255)))
def test_the_read_command_goes_out_twice(self):
self.backend.info(fake_device())
self.assertEqual(self.pad.seen, 2)
self.assertEqual(len(self.pad.acted), 1)
def test_write_lands_once_and_is_read_back(self):
dev = fake_device()
self.backend.write(dev, "key9", core.Binding(keys="h,e,l,l,o"), self.state,
_write=self.pad.write_report)
sent = [w for w in self.pad.written if w[1] == 0x0d]
self.assertEqual(len(sent), 2, "the record goes out twice")
self.assertEqual(sent[0], sent[1])
self.assertEqual(sent[0][:10], bytes.fromhex(WRITES[2][0]))
self.assertEqual(self.pad.records[9][:14], bytes.fromhex(WRITES[2][1]))
self.assertEqual(self.state.get("key9").binding.keys, "h,e,l,l,o")
self.assertFalse(self.state.get("key9").unknown)
def test_a_write_the_pad_did_not_take_is_reported(self):
"""A pad that stores something else must not be believed."""
dev = fake_device()
real = self.pad._receive
def stubborn(report):
out = real(report)
if report[1] == 0x0d:
self.pad.records[9] = REAL_DUMP[8] # it kept 'l'
return out
self.pad._receive = stubborn
with self.assertRaises(core.WriteError) as cm:
self.backend.write(dev, "key9", core.Binding(keys="m"), self.state,
_write=self.pad.write_report)
self.assertTrue(cm.exception.pad_touched)
self.assertTrue(self.state.get("key9").unknown)
def test_an_impossible_binding_touches_nothing(self):
with self.assertRaises(core.WriteError) as cm:
self.backend.write(fake_device(), "key1", core.Binding(keys="ctrl+a,b"),
self.state, _write=self.pad.write_report)
self.assertFalse(cm.exception.pad_touched)
self.assertEqual(self.pad.seen, 0)
def test_check_explains_before_writing(self):
self.assertIsNone(self.backend.check(core.Binding(keys="ctrl+c")))
self.assertIn("modifiers", self.backend.check(core.Binding(keys="ctrl+a,b")))
def test_set_lighting(self):
self.backend.set_lighting(fake_device(), wire.RGB_CUSTOM, (0xff, 0x88, 0x00),
_write=self.pad.write_report)
self.assertEqual([a[:6] for a in self.pad.acted],
[bytes.fromhex("00 02 57 0e 00 00"), bytes.fromhex("00 04 70 88 ff 00")])
class Detecting(unittest.TestCase):
def setUp(self):
self.pad = FakePad()
self.backend = Itprotech()
patch = mock.patch.object(core, "_exchange", self.pad.exchange)
patch.start()
self.addCleanup(patch.stop)
def test_the_mk09_is_recognised_as_three_by_three(self):
found = self.backend.detect(fake_device())
self.assertTrue(found.speaks)
self.assertEqual((found.keys, found.knobs), (9, 0))
self.assertEqual((found.layout.rows, found.layout.cols), (3, 3))
self.assertEqual(found.bindings["key5"].keys, "g")
self.assertIn("MK-09", found.model)
def test_a_sibling_is_named_by_its_count(self):
found = self.backend.detect(fake_device(pid="66ff"))
self.assertTrue(found.speaks)
self.assertEqual(found.model, "ITPROTECH 9 keys")
def test_silence_is_not_taken_as_yes(self):
self.pad.records.clear()
self.pad.other.clear()
self.assertFalse(self.backend.detect(fake_device()).speaks)
def test_a_gap_in_the_keys_is_not_a_pad_we_know(self):
del self.pad.records[5]
found = self.backend.detect(fake_device())
self.assertFalse(found.speaks)
self.assertIn("not 1 to 8", found.why)
class Registry(unittest.TestCase):
def test_the_mk09_is_claimed_and_siblings_are_not(self):
self.assertIsInstance(backends.for_vid_pid("8808", "660a"), Itprotech)
self.assertIsNone(backends.for_vid_pid("8808", "6604"))
self.assertTrue(Itprotech().could_claim("8808", "6604"))
def test_the_udev_rule_covers_the_vendor(self):
self.assertIn('ATTRS{idVendor}=="8808"', Itprotech().udev_rule())
self.assertTrue(any('"8808"' in l for l in core.rule_lines()))
def test_sequences_are_allowed_here_and_not_on_the_ch57x(self):
self.assertTrue(Itprotech().sequences)
self.assertEqual(backends.CH57x().sequences, core.SEQUENCES_VERIFIED)
def test_known_layout(self):
lay = core.KNOWN_LAYOUTS[("8808", "660a")]
self.assertEqual((lay.keys, lay.knobs, lay.rows, lay.cols), (9, 0, 3, 3))
if __name__ == "__main__":
unittest.main()
class Lighting(unittest.TestCase):
def setUp(self):
self.pad = FakePad()
self.backend = Itprotech()
patch = mock.patch.object(core, "_exchange", self.pad.exchange)
patch.start()
self.addCleanup(patch.stop)
def test_the_backend_offers_its_modes_to_the_gui(self):
self.assertEqual(len(self.backend.lighting_modes), 15)
self.assertEqual(self.backend.lighting_modes[self.backend.lighting_custom],
"custom colour")
def test_a_read_remembers_the_lighting(self):
dev = fake_device()
self.assertIsNone(self.backend.last_lighting(dev))
self.backend.read_layer(dev)
self.assertEqual(self.backend.last_lighting(dev), (12, (255, 255, 255)))
def test_setting_updates_what_is_remembered(self):
dev = fake_device()
self.backend.read_layer(dev)
self.backend.set_lighting(dev, 3, _write=self.pad.write_report)
self.assertEqual(self.backend.last_lighting(dev), (3, (255, 255, 255)))
self.backend.set_lighting(dev, wire.RGB_CUSTOM, (1, 2, 3),
_write=self.pad.write_report)
self.assertEqual(self.backend.last_lighting(dev), (14, (1, 2, 3)))
class LightingMenu(unittest.TestCase):
"""The GUI's Lighting menu, offscreen, against the fake pad."""
def test_the_menu_appears_for_this_pad_and_writes_to_it(self):
import os
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")
import macropad as proto
pad = FakePad()
dev = fake_device()
ready = core.Diagnosis("ready", f"Ready on {dev.path}.", device=dev)
with tempfile.TemporaryDirectory() as d, \
mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": d}), \
mock.patch.object(core, "diagnose", return_value=ready), \
mock.patch.object(core, "_exchange", pad.exchange), \
mock.patch.object(proto, "write_report", pad.write_report):
QApplication.instance() or QApplication([])
w = app.Window()
w.timer.stop()
w._read(quiet=True)
self.assertFalse(w.light_btn.isHidden())
self.assertEqual(w.state.device_id, "8808:660a")
self.assertEqual(w.state.layout.describe(), "9 keys in 3x3")
# the pad said mode 12, so that one is ticked
self.assertTrue(w.light_actions[12].isChecked())
self.assertIn("#ffffff", w.light_custom.text())
# choosing a mode sends it, twice, and ticks it
w.light_actions[3].trigger()
sent = [r for r in pad.written if r[1:3] == bytes([2, wire.CMD_RGB])]
self.assertEqual([r[3] for r in sent], [3, 3])
self.assertEqual([a[3] for a in pad.acted if a[2] == wire.CMD_RGB], [3])
self.assertTrue(w.light_actions[3].isChecked())
self.assertIn("press: green", w.message.text())
# a custom colour chosen without the dialog goes out as mode 14 + colour
w._set_lighting(wire.RGB_CUSTOM, (0x10, 0x20, 0x30))
colours = [a for a in pad.acted if a[2] == wire.CMD_COLOUR]
self.assertEqual(colours[-1][3:6], bytes([0x20, 0x10, 0x30]))
self.assertTrue(w.light_custom.isChecked())
self.assertIn("#102030", w.light_custom.text())
# no pad with lighting: the button goes away
w.diag = core.Diagnosis("unplugged", "No pad connected.")
w._follow_device()
self.assertTrue(w.light_btn.isHidden())