""" 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. Run this module for the two things the GUI has no place for yet, lighting and colour: python3 -m macropad_gui.backends.itprotech show python3 -m macropad_gui.backends.itprotech rgb "press: blue" python3 -m macropad_gui.backends.itprotech 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 def __init__(self): self._data_len = {} # device path -> key record length seen # ------------------------------------------------------------ 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"] 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"] # --------------------------------------------------------------- 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) # -------------------------------------------------------------- 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}") # -------------------------------------------------------------------- cli def _main(argv): import sys from .. import core def usage(): print(__doc__.split("Run this module")[1].strip("\n")) return 2 if not argv or argv[0] not in ("show", "rgb", "colour", "color"): return usage() d = core.diagnose(keyd=False) if not d.device or d.device.vid != wire.VID: print("No ITPROTECH pad found." if not d.device else d.headline) return 1 backend = Itprotech() dev = d.device if argv[0] == "show": dump = backend._dump(dev) for index, report in sorted(dump["keys"].items()): got = wire.decode(report) label = got[1].label() if got and got[1] else "(not decodable)" print(f" key{index:<3} {report[:8].hex(' ')} {label}") mode = dump["rgb"] name = wire.RGB_MODES[mode] if mode is not None and mode < len(wire.RGB_MODES) else "?" print(f" lighting: {mode} ({name}), colour " f"{'#%02x%02x%02x' % dump['colour'] if dump['colour'] else '?'}") return 0 if not d.ready: print(d.headline) return 1 if argv[0] == "rgb": if len(argv) < 2: return usage() want = argv[1].lower() mode = int(want) if want.isdigit() else ( wire.RGB_MODES.index(want) if want in wire.RGB_MODES else None) if mode is None: print("Modes: " + ", ".join(f"{i} {m}" for i, m in enumerate(wire.RGB_MODES))) return 1 backend.set_lighting(dev, mode) print(f"Lighting set to {mode} ({wire.RGB_MODES[mode]}).") return 0 hexcol = argv[1].lstrip("#") if len(argv) > 1 else "" if len(hexcol) != 6: 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(dev, wire.RGB_CUSTOM, rgb) print(f"Custom colour #{hexcol} set, lighting mode {wire.RGB_CUSTOM}.") return 0 if __name__ == "__main__": import sys sys.exit(_main(sys.argv[1:]))