""" 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 , then 04 64 for the custom colour 0d ... bind one key; 0x0d is the per-model payload length (13 on the 9 key pad, see DATA_LEN) 02 57 lighting mode 0..14, see RGB_MODES 04 70 custom colour for mode 14. Green first. Really. 01 56 enter the firmware bootloader. NEVER SEND THIS. Key types: 01 keyboard 02 mouse (not offered here) 03 media 04 00 consumer page usage 04 multikey ... 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 REPLY_COLOUR = 0x64 # 04 64 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 ""