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)