QUELLCODE / PY
keyboard.py
src/hardware_box/hid/keyboard.py
1# OpenAss: nur privater, experimenteller Gebrauch; auf eigene Verantwortung.2# Niemals Unternehmensrechner, Produktivsysteme, Industrieanlagen oder3# sicherheitskritische Systeme anschließen oder bedienen.4# Haftungshinweis und gesetzliche Grenzen: ../../../DISCLAIMER.txt.5"""PC USB keyboard mapping for standard US and DE/AT Windows/Linux layouts.67Select the layout actually configured on the target. CapsLock and NumLock must8be known off; this example cannot observe target LED state or input methods.9DE/AT mappings assume a standard ISO PC keyboard with right Alt as AltGr.10Mac German, US International, custom layouts and IMEs need separate profiles.11No clipboard or operating-system Unicode-input tricks are used. Unsupported12text is rejected as a whole before the first keystroke.13"""1415from dataclasses import dataclass16from collections.abc import Sequence1718LEFT_CTRL, LEFT_SHIFT, LEFT_ALT, LEFT_GUI = 0x01, 0x02, 0x04, 0x0819RIGHT_CTRL, RIGHT_SHIFT, RIGHT_ALT, RIGHT_GUI = 0x10, 0x20, 0x40, 0x80202122class KeyboardMappingError(ValueError):23 pass242526@dataclass(frozen=True)27class KeyStroke:28 modifiers: int29 usages: tuple[int, ...]3031 def report_arguments(self) -> tuple[int, ...]:32 return (self.modifiers, *self.usages, *((0,) * (6 - len(self.usages))))333435_MODIFIERS = {36 "CTRL": LEFT_CTRL, "CTL": LEFT_CTRL, "CONTROL": LEFT_CTRL, "LEFTCTRL": LEFT_CTRL, "LCTRL": LEFT_CTRL,37 "SHIFT": LEFT_SHIFT, "LEFTSHIFT": LEFT_SHIFT, "LSHIFT": LEFT_SHIFT,38 "ALT": LEFT_ALT, "OPTION": LEFT_ALT, "LEFTALT": LEFT_ALT, "LALT": LEFT_ALT,39 "META": LEFT_GUI, "SUPER": LEFT_GUI, "CMD": LEFT_GUI, "COMMAND": LEFT_GUI,40 "WIN": LEFT_GUI, "WINDOWS": LEFT_GUI, "GUI": LEFT_GUI, "LEFTMETA": LEFT_GUI,41 "RIGHTCTRL": RIGHT_CTRL, "RCTRL": RIGHT_CTRL, "RIGHTSHIFT": RIGHT_SHIFT, "RSHIFT": RIGHT_SHIFT,42 "ALTGR": RIGHT_ALT, "RIGHTALT": RIGHT_ALT, "RALT": RIGHT_ALT,43 "RIGHTMETA": RIGHT_GUI, "RIGHTGUI": RIGHT_GUI, "RGUI": RIGHT_GUI,44}45_NAMED = {46 "ENTER": 40, "RETURN": 40, "ESC": 41, "ESCAPE": 41, "BACKSPACE": 42,47 "TAB": 43, "SPACE": 44, "SPACEBAR": 44,48 "PRINTSCREEN": 70, "PRTSC": 70, "SCROLLLOCK": 71, "PAUSE": 72,49 "INSERT": 73, "HOME": 74, "PAGEUP": 75, "PGUP": 75,50 "DELETE": 76, "DEL": 76, "END": 77, "PAGEDOWN": 78, "PGDN": 78,51 "RIGHT": 79, "ARROWRIGHT": 79, "LEFT": 80, "ARROWLEFT": 80,52 "DOWN": 81, "ARROWDOWN": 81, "UP": 82, "ARROWUP": 82,53 "CONTEXTMENU": 101, "MENU": 101,54 **{f"F{n}": 57 + n for n in range(1, 13)},55}56_LOCK_KEYS = {"CAPSLOCK", "NUMLOCK"}575859def normalize_key_name(key: str) -> str:60 """Canonical policy identity; sided modifier aliases cannot bypass rules.6162 The driver still validates whether a key can be emitted. The safety layer63 must call full keypress preflight before using these canonical names.64 Right Alt/AltGr maps to ALT for restrictions even on a DE/AT target.65 """66 if not isinstance(key, str) or not key.strip():67 raise KeyboardMappingError("invalid key name")68 token = key.strip().upper().replace("_", "").replace(" ", "")69 if token in _MODIFIERS:70 mask = _MODIFIERS[token]71 if mask & (LEFT_CTRL | RIGHT_CTRL):72 return "CTRL"73 if mask & (LEFT_SHIFT | RIGHT_SHIFT):74 return "SHIFT"75 if mask & (LEFT_ALT | RIGHT_ALT):76 return "ALT"77 return "META"78 aliases = {"RETURN": "ENTER", "ESCAPE": "ESC", "DEL": "DELETE", "SPACEBAR": "SPACE",79 "ARROWRIGHT": "RIGHT", "ARROWLEFT": "LEFT", "ARROWUP": "UP", "ARROWDOWN": "DOWN",80 "PGUP": "PAGEUP", "PGDN": "PAGEDOWN", "PRTSC": "PRINTSCREEN", "MENU": "CONTEXTMENU"}81 return aliases.get(token, token)828384def _letter_usage(char: str, german: bool) -> int:85 if german and char == "y":86 return 2987 if german and char == "z":88 return 2889 return 4 + ord(char) - ord("a")909192def _table(german: bool) -> dict[str, tuple[KeyStroke, ...]]:93 result: dict[str, tuple[KeyStroke, ...]] = {}9495 def add(char: str, usage: int, modifiers: int = 0, dead: bool = False) -> None:96 result[char] = (KeyStroke(modifiers, (usage,)),) + ((KeyStroke(0, (44,)),) if dead else ())9798 for i in range(26):99 char = chr(ord("a") + i)100 add(char, _letter_usage(char, german))101 add(char.upper(), _letter_usage(char, german), LEFT_SHIFT)102 for char, usage in zip("1234567890", range(30, 40)):103 add(char, usage)104 add(" ", 44)105 add("\n", 40)106 add("\t", 43)107 if not german:108 for char, usage in {"-": 45, "=": 46, "[": 47, "]": 48, "\\": 49,109 ";": 51, "'": 52, "`": 53, ",": 54, ".": 55, "/": 56}.items():110 add(char, usage)111 for char, usage in zip("!@#$%^&*()", range(30, 40)):112 add(char, usage, LEFT_SHIFT)113 for char, usage in {"_": 45, "+": 46, "{": 47, "}": 48, "|": 49,114 ":": 51, '"': 52, "~": 53, "<": 54, ">": 55, "?": 56}.items():115 add(char, usage, LEFT_SHIFT)116 else:117 for char, usage in zip('!"§$%&/()=', range(30, 40)):118 add(char, usage, LEFT_SHIFT)119 for char, usage in {"ß": 45, "+": 48, "#": 49, "ü": 47, "ö": 51, "ä": 52,120 ",": 54, ".": 55, "-": 56, "<": 100}.items():121 add(char, usage)122 for char, usage in {"?": 45, "*": 48, "'": 49, "Ü": 47, "Ö": 51, "Ä": 52,123 ";": 54, ":": 55, "_": 56, ">": 100}.items():124 add(char, usage, LEFT_SHIFT)125 for char, usage in {"@": 20, "€": 8, "{": 36, "[": 37, "]": 38, "}": 39,126 "\\": 45, "~": 48, "|": 100}.items():127 add(char, usage, RIGHT_ALT)128 add("^", 53, dead=True)129 add("`", 46, LEFT_SHIFT, dead=True)130 return result131132133class KeyboardLayout:134 """Preflight text and combos for an explicitly selected target PC layout."""135136 def __init__(self, name: str = "us") -> None:137 if not isinstance(name, str):138 raise KeyboardMappingError("keyboard layout must be a string")139 name = name.lower().replace("-", "_")140 if name not in {"us", "us_windows", "us_linux", "de", "de_windows", "de_linux", "at", "at_windows", "at_linux"}:141 raise KeyboardMappingError("unsupported layout; choose US or standard DE/AT Windows/Linux")142 self.name = name143 self.german = name.split("_")[0] in {"de", "at"}144 self._characters = _table(self.german)145146 def preflight_text(self, text: str) -> tuple[KeyStroke, ...]:147 if not isinstance(text, str):148 raise KeyboardMappingError("text must be a string")149 strokes = []150 for index, char in enumerate(text):151 if char not in self._characters:152 raise KeyboardMappingError(f"unsupported character U+{ord(char):04X} at index {index} for {self.name}")153 strokes.extend(self._characters[char])154 return tuple(strokes)155156 def keypress(self, keys: str | Sequence[str]) -> KeyStroke:157 """Combos use target-layout letters, e.g. Ctrl+Y uses ISO Y on DE."""158 if isinstance(keys, str):159 keys = keys.split("+")160 elif not isinstance(keys, Sequence):161 raise KeyboardMappingError("keypress requires a key name or list of names")162 if not keys or len(keys) > 10:163 raise KeyboardMappingError("keypress must contain 1..10 key names")164 modifiers = 0165 usages = []166 for key in keys:167 if not isinstance(key, str) or not key.strip():168 raise KeyboardMappingError("invalid key name")169 key = key.strip()170 token = key.strip().upper().replace("_", "").replace(" ", "")171 if token in _LOCK_KEYS:172 raise KeyboardMappingError("lock keys are disabled; configure target lock state manually")173 if token in _MODIFIERS:174 modifiers |= _MODIFIERS[token]175 continue176 if token in _NAMED:177 usage = _NAMED[token]178 else:179 # Named uppercase letters mean a physical key, not shifted text.180 character = key.lower() if len(key) == 1 and key.isalpha() else key181 if token == "PLUS":182 character = "+"183 strokes = self._characters.get(character)184 if strokes is None or len(strokes) != 1:185 raise KeyboardMappingError(f"unsupported key name: {key}")186 modifiers |= strokes[0].modifiers187 usage = strokes[0].usages[0]188 if usage in usages:189 raise KeyboardMappingError("duplicate non-modifier key")190 usages.append(usage)191 if len(usages) > 6:192 raise KeyboardMappingError("at most six non-modifier keys can be held")193 return KeyStroke(modifiers, tuple(usages))194195196def map_text(text: str, layout: str = "us") -> tuple[KeyStroke, ...]:197 return KeyboardLayout(layout).preflight_text(text)198199200def map_keypress(keys: str | Sequence[str], layout: str = "us") -> KeyStroke:201 return KeyboardLayout(layout).keypress(keys)