Compare commits
4 Commits
main
...
itprotech-
| Author | SHA1 | Date | |
|---|---|---|---|
| 625c95f09c | |||
| 4f33161c70 | |||
| 021f9f7fa1 | |||
| 80c6a5da1c |
27
README.md
27
README.md
@@ -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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
71
docs/protocol-itprotech.md
Normal file
71
docs/protocol-itprotech.md
Normal 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.
|
||||||
20
install.sh
20
install.sh
@@ -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"
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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__":
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
213
macropad_gui/backends/itprotech.py
Normal file
213
macropad_gui/backends/itprotech.py
Normal 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}")
|
||||||
|
|
||||||
|
|
||||||
286
macropad_gui/backends/itprotech_proto.py
Normal file
286
macropad_gui/backends/itprotech_proto.py
Normal 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 ""
|
||||||
@@ -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,7 +891,10 @@ def _exchange(path, request, want=None):
|
|||||||
except OSError:
|
except OSError:
|
||||||
break
|
break
|
||||||
try:
|
try:
|
||||||
os.write(fd, request)
|
for i in range(repeat):
|
||||||
|
if i:
|
||||||
|
time.sleep(WRITE_GAP)
|
||||||
|
os.write(fd, request)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
raise ReadError(_explain(e)) from e
|
raise ReadError(_explain(e)) from e
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
415
tests/test_itprotech.py
Normal 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())
|
||||||
Reference in New Issue
Block a user