diff --git a/docs/API.md b/docs/API.md index fc051ce..89cd480 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,6 +1,6 @@ # nanokvm API Reference -**Version:** 0.1.1 +**Version:** 0.1.2 **License:** MIT **Python:** >= 3.12 diff --git a/examples/ai_agent_loop.py b/examples/ai_agent_loop.py index 8674ebf..45c00f4 100644 --- a/examples/ai_agent_loop.py +++ b/examples/ai_agent_loop.py @@ -7,7 +7,6 @@ from __future__ import annotations -import json import time from nanokvm import NanoKVM diff --git a/examples/mouse_control.py b/examples/mouse_control.py index 9269ff7..861414d 100644 --- a/examples/mouse_control.py +++ b/examples/mouse_control.py @@ -71,10 +71,14 @@ def _record_loop(self) -> None: if writer is None: h, w = frame.shape[:2] - fourcc = cv2.VideoWriter_fourcc(*"mp4v") - writer = cv2.VideoWriter(self._output_path, fourcc, self._fps, (w, h)) + fourcc = cv2.VideoWriter_fourcc(*"mp4v") # type: ignore[reportAttributeAccessIssue] + writer = cv2.VideoWriter( + self._output_path, fourcc, self._fps, (w, h) + ) if not writer.isOpened(): - print(f" [recorder] Failed to open VideoWriter for {self._output_path}") + print( + f" [recorder] Failed to open VideoWriter for {self._output_path}" + ) return writer.write(frame) @@ -102,11 +106,11 @@ def test_absolute_mode(kvm: NanoKVM) -> None: print("Moving cursor to five positions (corners + center)...\n") positions = [ - ("top-left", 0.05, 0.05), - ("top-right", 0.95, 0.05), + ("top-left", 0.05, 0.05), + ("top-right", 0.95, 0.05), ("bottom-right", 0.95, 0.95), - ("bottom-left", 0.05, 0.95), - ("center", 0.50, 0.50), + ("bottom-left", 0.05, 0.95), + ("center", 0.50, 0.50), ] for name, x, y in positions: @@ -157,11 +161,11 @@ def test_relative_move_to(kvm: NanoKVM) -> None: print("Visits corners using reset-to-origin + relative movement.\n") positions = [ - ("top-left", 0.0, 0.0), - ("top-right", 1.0, 0.0), + ("top-left", 0.0, 0.0), + ("top-right", 1.0, 0.0), ("bottom-right", 1.0, 1.0), - ("bottom-left", 0.0, 1.0), - ("center", 0.5, 0.5), + ("bottom-left", 0.0, 1.0), + ("center", 0.5, 0.5), ] for name, x, y in positions: @@ -196,10 +200,23 @@ def test_scroll(kvm: NanoKVM) -> None: def main() -> None: parser = argparse.ArgumentParser(description="NanoKVM mouse control test") parser.add_argument("--port", default="/dev/ttyACM0", help="Serial port") - parser.add_argument("--video", type=int, default=None, help="Video device index (optional)") - parser.add_argument("--record", action="store_true", help="Record the KVM screen during tests") - parser.add_argument("--output", default=None, help="Output video file path (default: recording_.mp4)") - parser.add_argument("--record-fps", type=float, default=10, help="Recording frame rate (default: 10)") + parser.add_argument( + "--video", type=int, default=None, help="Video device index (optional)" + ) + parser.add_argument( + "--record", action="store_true", help="Record the KVM screen during tests" + ) + parser.add_argument( + "--output", + default=None, + help="Output video file path (default: recording_.mp4)", + ) + parser.add_argument( + "--record-fps", + type=float, + default=10, + help="Recording frame rate (default: 10)", + ) args = parser.parse_args() if args.record and args.video is None: diff --git a/examples/open_chrome_win11.py b/examples/open_chrome_win11.py index 86c51c6..4e3a4f3 100644 --- a/examples/open_chrome_win11.py +++ b/examples/open_chrome_win11.py @@ -52,11 +52,11 @@ def record_loop() -> None: # --- Move mouse to each corner and center --- positions = [ - ("top-left", 0.0, 0.0), - ("top-right", 1.0, 0.0), + ("top-left", 0.0, 0.0), + ("top-right", 1.0, 0.0), ("bottom-right", 1.0, 1.0), - ("bottom-left", 0.0, 1.0), - ("center", 0.5, 0.5), + ("bottom-left", 0.0, 1.0), + ("center", 0.5, 0.5), ] for name, x, y in positions: diff --git a/main.py b/main.py index ab0b38c..9bc2740 100644 --- a/main.py +++ b/main.py @@ -16,7 +16,9 @@ def main() -> None: devices = VideoCapture.list_devices() if devices: for d in devices: - print(f" Index {d['index']}: {d['width']}x{d['height']} @ {d['fps']}fps ({d['backend']})") + print( + f" Index {d['index']}: {d['width']}x{d['height']} @ {d['fps']}fps ({d['backend']})" + ) else: print(" (none found)") diff --git a/nanokvm/__init__.py b/nanokvm/__init__.py index c8e7a98..3ea2e0b 100644 --- a/nanokvm/__init__.py +++ b/nanokvm/__init__.py @@ -26,7 +26,7 @@ from .serial_conn import SerialConnection from .video import VideoCapture -__version__ = "0.1.1" +__version__ = "0.1.2" __all__ = [ "NanoKVM", diff --git a/nanokvm/__main__.py b/nanokvm/__main__.py index ab0b38c..9bc2740 100644 --- a/nanokvm/__main__.py +++ b/nanokvm/__main__.py @@ -16,7 +16,9 @@ def main() -> None: devices = VideoCapture.list_devices() if devices: for d in devices: - print(f" Index {d['index']}: {d['width']}x{d['height']} @ {d['fps']}fps ({d['backend']})") + print( + f" Index {d['index']}: {d['width']}x{d['height']} @ {d['fps']}fps ({d['backend']})" + ) else: print(" (none found)") diff --git a/nanokvm/device.py b/nanokvm/device.py index 26e56d5..3b3f659 100644 --- a/nanokvm/device.py +++ b/nanokvm/device.py @@ -9,9 +9,8 @@ import time -from .keyboard import KeyboardReport, is_modifier, resolve_key_code +from .keyboard import KeyboardReport, resolve_key_code from .mouse import ( - MouseButton, build_absolute_report, build_relative_report, resolve_button, @@ -93,7 +92,9 @@ def connect( info = self.get_info() if vdev is not None: - self._video.open(vdev, self._video_width, self._video_height, self._video_fps) + self._video.open( + vdev, self._video_width, self._video_height, self._video_fps + ) return info @@ -199,7 +200,11 @@ def type_text(self, text: str, delay: float = INTER_KEY_DELAY) -> None: # ------------------------------------------------------------------ def _send_mouse(self, report: list[int]) -> None: - pkt_cmd = CmdEvent.SEND_MS_REL_DATA if report[0] == 0x01 else CmdEvent.SEND_MS_ABS_DATA + pkt_cmd = ( + CmdEvent.SEND_MS_REL_DATA + if report[0] == 0x01 + else CmdEvent.SEND_MS_ABS_DATA + ) pkt = CmdPacket(addr=self._addr, cmd=pkt_cmd, data=report) self._serial.write(pkt.encode()) @@ -276,7 +281,9 @@ def mouse_move_relative(self, dx: int, dy: int, step_delay: float = 0.005) -> No while dx != 0 or dy != 0: chunk_x = max(-127, min(127, dx)) chunk_y = max(-127, min(127, dy)) - report = build_relative_report(dx=chunk_x, dy=chunk_y, buttons=self._buttons) + report = build_relative_report( + dx=chunk_x, dy=chunk_y, buttons=self._buttons + ) self._send_mouse(report) dx -= chunk_x dy -= chunk_y diff --git a/nanokvm/keyboard.py b/nanokvm/keyboard.py index 6523c5d..780e9e1 100644 --- a/nanokvm/keyboard.py +++ b/nanokvm/keyboard.py @@ -44,112 +44,261 @@ # --------------------------------------------------------------------------- KEYCODE_MAP: dict[str, int] = { # Letters - "KeyA": 0x04, "KeyB": 0x05, "KeyC": 0x06, "KeyD": 0x07, - "KeyE": 0x08, "KeyF": 0x09, "KeyG": 0x0A, "KeyH": 0x0B, - "KeyI": 0x0C, "KeyJ": 0x0D, "KeyK": 0x0E, "KeyL": 0x0F, - "KeyM": 0x10, "KeyN": 0x11, "KeyO": 0x12, "KeyP": 0x13, - "KeyQ": 0x14, "KeyR": 0x15, "KeyS": 0x16, "KeyT": 0x17, - "KeyU": 0x18, "KeyV": 0x19, "KeyW": 0x1A, "KeyX": 0x1B, - "KeyY": 0x1C, "KeyZ": 0x1D, + "KeyA": 0x04, + "KeyB": 0x05, + "KeyC": 0x06, + "KeyD": 0x07, + "KeyE": 0x08, + "KeyF": 0x09, + "KeyG": 0x0A, + "KeyH": 0x0B, + "KeyI": 0x0C, + "KeyJ": 0x0D, + "KeyK": 0x0E, + "KeyL": 0x0F, + "KeyM": 0x10, + "KeyN": 0x11, + "KeyO": 0x12, + "KeyP": 0x13, + "KeyQ": 0x14, + "KeyR": 0x15, + "KeyS": 0x16, + "KeyT": 0x17, + "KeyU": 0x18, + "KeyV": 0x19, + "KeyW": 0x1A, + "KeyX": 0x1B, + "KeyY": 0x1C, + "KeyZ": 0x1D, # Digits - "Digit1": 0x1E, "Digit2": 0x1F, "Digit3": 0x20, "Digit4": 0x21, - "Digit5": 0x22, "Digit6": 0x23, "Digit7": 0x24, "Digit8": 0x25, - "Digit9": 0x26, "Digit0": 0x27, + "Digit1": 0x1E, + "Digit2": 0x1F, + "Digit3": 0x20, + "Digit4": 0x21, + "Digit5": 0x22, + "Digit6": 0x23, + "Digit7": 0x24, + "Digit8": 0x25, + "Digit9": 0x26, + "Digit0": 0x27, # Special keys - "Enter": 0x28, "Escape": 0x29, "Backspace": 0x2A, "Tab": 0x2B, - "Space": 0x2C, "Minus": 0x2D, "Equal": 0x2E, "BracketLeft": 0x2F, - "BracketRight": 0x30, "Backslash": 0x31, "IntlHash": 0x32, - "Semicolon": 0x33, "Quote": 0x34, "Backquote": 0x35, - "Comma": 0x36, "Period": 0x37, "Slash": 0x38, "CapsLock": 0x39, + "Enter": 0x28, + "Escape": 0x29, + "Backspace": 0x2A, + "Tab": 0x2B, + "Space": 0x2C, + "Minus": 0x2D, + "Equal": 0x2E, + "BracketLeft": 0x2F, + "BracketRight": 0x30, + "Backslash": 0x31, + "IntlHash": 0x32, + "Semicolon": 0x33, + "Quote": 0x34, + "Backquote": 0x35, + "Comma": 0x36, + "Period": 0x37, + "Slash": 0x38, + "CapsLock": 0x39, # Function keys - "F1": 0x3A, "F2": 0x3B, "F3": 0x3C, "F4": 0x3D, - "F5": 0x3E, "F6": 0x3F, "F7": 0x40, "F8": 0x41, - "F9": 0x42, "F10": 0x43, "F11": 0x44, "F12": 0x45, + "F1": 0x3A, + "F2": 0x3B, + "F3": 0x3C, + "F4": 0x3D, + "F5": 0x3E, + "F6": 0x3F, + "F7": 0x40, + "F8": 0x41, + "F9": 0x42, + "F10": 0x43, + "F11": 0x44, + "F12": 0x45, # Control keys - "PrintScreen": 0x46, "ScrollLock": 0x47, "Pause": 0x48, - "Insert": 0x49, "Home": 0x4A, "PageUp": 0x4B, - "Delete": 0x4C, "End": 0x4D, "PageDown": 0x4E, + "PrintScreen": 0x46, + "ScrollLock": 0x47, + "Pause": 0x48, + "Insert": 0x49, + "Home": 0x4A, + "PageUp": 0x4B, + "Delete": 0x4C, + "End": 0x4D, + "PageDown": 0x4E, # Arrow keys - "ArrowRight": 0x4F, "ArrowLeft": 0x50, "ArrowDown": 0x51, "ArrowUp": 0x52, + "ArrowRight": 0x4F, + "ArrowLeft": 0x50, + "ArrowDown": 0x51, + "ArrowUp": 0x52, # Numpad - "NumLock": 0x53, "NumpadDivide": 0x54, "NumpadMultiply": 0x55, - "NumpadSubtract": 0x56, "NumpadAdd": 0x57, "NumpadEnter": 0x58, - "Numpad1": 0x59, "Numpad2": 0x5A, "Numpad3": 0x5B, - "Numpad4": 0x5C, "Numpad5": 0x5D, "Numpad6": 0x5E, - "Numpad7": 0x5F, "Numpad8": 0x60, "Numpad9": 0x61, - "Numpad0": 0x62, "NumpadDecimal": 0x63, + "NumLock": 0x53, + "NumpadDivide": 0x54, + "NumpadMultiply": 0x55, + "NumpadSubtract": 0x56, + "NumpadAdd": 0x57, + "NumpadEnter": 0x58, + "Numpad1": 0x59, + "Numpad2": 0x5A, + "Numpad3": 0x5B, + "Numpad4": 0x5C, + "Numpad5": 0x5D, + "Numpad6": 0x5E, + "Numpad7": 0x5F, + "Numpad8": 0x60, + "Numpad9": 0x61, + "Numpad0": 0x62, + "NumpadDecimal": 0x63, # International - "IntlBackslash": 0x64, "ContextMenu": 0x65, "Power": 0x66, + "IntlBackslash": 0x64, + "ContextMenu": 0x65, + "Power": 0x66, "NumpadEqual": 0x67, # Extended function keys - "F13": 0x68, "F14": 0x69, "F15": 0x6A, "F16": 0x6B, - "F17": 0x6C, "F18": 0x6D, "F19": 0x6E, "F20": 0x6F, - "F21": 0x70, "F22": 0x71, "F23": 0x72, "F24": 0x73, + "F13": 0x68, + "F14": 0x69, + "F15": 0x6A, + "F16": 0x6B, + "F17": 0x6C, + "F18": 0x6D, + "F19": 0x6E, + "F20": 0x6F, + "F21": 0x70, + "F22": 0x71, + "F23": 0x72, + "F24": 0x73, # System / Edit - "Execute": 0x74, "Help": 0x75, "Props": 0x76, "Select": 0x77, - "Stop": 0x78, "Again": 0x79, "Undo": 0x7A, "Cut": 0x7B, - "Copy": 0x7C, "Paste": 0x7D, "Find": 0x7E, + "Execute": 0x74, + "Help": 0x75, + "Props": 0x76, + "Select": 0x77, + "Stop": 0x78, + "Again": 0x79, + "Undo": 0x7A, + "Cut": 0x7B, + "Copy": 0x7C, + "Paste": 0x7D, + "Find": 0x7E, # Media / Volume - "AudioVolumeMute": 0x7F, "AudioVolumeUp": 0x80, "AudioVolumeDown": 0x81, - "VolumeMute": 0x7F, "VolumeUp": 0x80, "VolumeDown": 0x81, + "AudioVolumeMute": 0x7F, + "AudioVolumeUp": 0x80, + "AudioVolumeDown": 0x81, + "VolumeMute": 0x7F, + "VolumeUp": 0x80, + "VolumeDown": 0x81, # Japanese keys - "IntlRo": 0x87, "KanaMode": 0x88, "IntlYen": 0x89, - "Convert": 0x8A, "NonConvert": 0x8B, + "IntlRo": 0x87, + "KanaMode": 0x88, + "IntlYen": 0x89, + "Convert": 0x8A, + "NonConvert": 0x8B, # Language keys - "Lang1": 0x90, "Lang2": 0x91, "Lang3": 0x92, - "Lang4": 0x93, "Lang5": 0x94, + "Lang1": 0x90, + "Lang2": 0x91, + "Lang3": 0x92, + "Lang4": 0x93, + "Lang5": 0x94, # Numpad extended - "NumpadParenLeft": 0xB6, "NumpadParenRight": 0xB7, + "NumpadParenLeft": 0xB6, + "NumpadParenRight": 0xB7, "NumpadBackspace": 0xBB, - "NumpadMemoryStore": 0xD0, "NumpadMemoryRecall": 0xD1, - "NumpadMemoryClear": 0xD2, "NumpadMemoryAdd": 0xD3, + "NumpadMemoryStore": 0xD0, + "NumpadMemoryRecall": 0xD1, + "NumpadMemoryClear": 0xD2, + "NumpadMemoryAdd": 0xD3, "NumpadMemorySubtract": 0xD4, - "NumpadClear": 0xD8, "NumpadClearEntry": 0xD9, + "NumpadClear": 0xD8, + "NumpadClearEntry": 0xD9, # Media keys - "MediaPlayPause": 0xE8, "MediaStop": 0xE9, - "MediaTrackPrevious": 0xEA, "MediaTrackNext": 0xEB, - "Eject": 0xEC, "MediaSelect": 0xED, + "MediaPlayPause": 0xE8, + "MediaStop": 0xE9, + "MediaTrackPrevious": 0xEA, + "MediaTrackNext": 0xEB, + "Eject": 0xEC, + "MediaSelect": 0xED, # App launch - "LaunchMail": 0xEE, "LaunchApp1": 0xEF, "LaunchApp2": 0xF0, + "LaunchMail": 0xEE, + "LaunchApp1": 0xEF, + "LaunchApp2": 0xF0, # Browser keys - "BrowserSearch": 0xF0, "BrowserHome": 0xF1, "BrowserBack": 0xF2, - "BrowserForward": 0xF3, "BrowserStop": 0xF4, "BrowserRefresh": 0xF5, + "BrowserSearch": 0xF0, + "BrowserHome": 0xF1, + "BrowserBack": 0xF2, + "BrowserForward": 0xF3, + "BrowserStop": 0xF4, + "BrowserRefresh": 0xF5, "BrowserFavorites": 0xF6, # Sleep / Wake / Accessibility - "Sleep": 0xF8, "Wake": 0xF9, - "MediaRewind": 0xFA, "MediaFastForward": 0xFB, + "Sleep": 0xF8, + "Wake": 0xF9, + "MediaRewind": 0xFA, + "MediaFastForward": 0xFB, # Modifier keycodes (HID 0xE0-0xE7) - "ControlLeft": 0xE0, "ShiftLeft": 0xE1, "AltLeft": 0xE2, "MetaLeft": 0xE3, - "WinLeft": 0xE3, "ControlRight": 0xE4, "ShiftRight": 0xE5, - "AltRight": 0xE6, "MetaRight": 0xE7, "WinRight": 0xE7, + "ControlLeft": 0xE0, + "ShiftLeft": 0xE1, + "AltLeft": 0xE2, + "MetaLeft": 0xE3, + "WinLeft": 0xE3, + "ControlRight": 0xE4, + "ShiftRight": 0xE5, + "AltRight": 0xE6, + "MetaRight": 0xE7, + "WinRight": 0xE7, } # Friendly key name aliases (case-insensitive lookup, see resolve_key_code()) _KEY_ALIASES: dict[str, str] = { - "enter": "Enter", "return": "Enter", - "esc": "Escape", "escape": "Escape", - "backspace": "Backspace", "bs": "Backspace", - "tab": "Tab", "space": "Space", - "capslock": "CapsLock", "caps": "CapsLock", - "delete": "Delete", "del": "Delete", - "insert": "Insert", "ins": "Insert", - "home": "Home", "end": "End", - "pageup": "PageUp", "pgup": "PageUp", - "pagedown": "PageDown", "pgdn": "PageDown", - "up": "ArrowUp", "down": "ArrowDown", - "left": "ArrowLeft", "right": "ArrowRight", - "printscreen": "PrintScreen", "prtsc": "PrintScreen", - "scrolllock": "ScrollLock", "pause": "Pause", + "enter": "Enter", + "return": "Enter", + "esc": "Escape", + "escape": "Escape", + "backspace": "Backspace", + "bs": "Backspace", + "tab": "Tab", + "space": "Space", + "capslock": "CapsLock", + "caps": "CapsLock", + "delete": "Delete", + "del": "Delete", + "insert": "Insert", + "ins": "Insert", + "home": "Home", + "end": "End", + "pageup": "PageUp", + "pgup": "PageUp", + "pagedown": "PageDown", + "pgdn": "PageDown", + "up": "ArrowUp", + "down": "ArrowDown", + "left": "ArrowLeft", + "right": "ArrowRight", + "printscreen": "PrintScreen", + "prtsc": "PrintScreen", + "scrolllock": "ScrollLock", + "pause": "Pause", "numlock": "NumLock", - "f1": "F1", "f2": "F2", "f3": "F3", "f4": "F4", - "f5": "F5", "f6": "F6", "f7": "F7", "f8": "F8", - "f9": "F9", "f10": "F10", "f11": "F11", "f12": "F12", - "minus": "Minus", "equal": "Equal", - "bracketleft": "BracketLeft", "bracketright": "BracketRight", - "backslash": "Backslash", "semicolon": "Semicolon", - "quote": "Quote", "backquote": "Backquote", - "comma": "Comma", "period": "Period", "slash": "Slash", - "contextmenu": "ContextMenu", "menu": "ContextMenu", + "f1": "F1", + "f2": "F2", + "f3": "F3", + "f4": "F4", + "f5": "F5", + "f6": "F6", + "f7": "F7", + "f8": "F8", + "f9": "F9", + "f10": "F10", + "f11": "F11", + "f12": "F12", + "minus": "Minus", + "equal": "Equal", + "bracketleft": "BracketLeft", + "bracketright": "BracketRight", + "backslash": "Backslash", + "semicolon": "Semicolon", + "quote": "Quote", + "backquote": "Backquote", + "comma": "Comma", + "period": "Period", + "slash": "Slash", + "contextmenu": "ContextMenu", + "menu": "ContextMenu", } # --------------------------------------------------------------------------- @@ -157,52 +306,102 @@ # Used for type_text() # --------------------------------------------------------------------------- CHAR_CODES: dict[int, int] = { - 48: 0x27, 49: 0x1E, 50: 0x1F, 51: 0x20, 52: 0x21, - 53: 0x22, 54: 0x23, 55: 0x24, 56: 0x25, 57: 0x26, + 48: 0x27, + 49: 0x1E, + 50: 0x1F, + 51: 0x20, + 52: 0x21, + 53: 0x22, + 54: 0x23, + 55: 0x24, + 56: 0x25, + 57: 0x26, # A-Z - 65: 0x04, 66: 0x05, 67: 0x06, 68: 0x07, 69: 0x08, - 70: 0x09, 71: 0x0A, 72: 0x0B, 73: 0x0C, 74: 0x0D, - 75: 0x0E, 76: 0x0F, 77: 0x10, 78: 0x11, 79: 0x12, - 80: 0x13, 81: 0x14, 82: 0x15, 83: 0x16, 84: 0x17, - 85: 0x18, 86: 0x19, 87: 0x1A, 88: 0x1B, 89: 0x1C, 90: 0x1D, + 65: 0x04, + 66: 0x05, + 67: 0x06, + 68: 0x07, + 69: 0x08, + 70: 0x09, + 71: 0x0A, + 72: 0x0B, + 73: 0x0C, + 74: 0x0D, + 75: 0x0E, + 76: 0x0F, + 77: 0x10, + 78: 0x11, + 79: 0x12, + 80: 0x13, + 81: 0x14, + 82: 0x15, + 83: 0x16, + 84: 0x17, + 85: 0x18, + 86: 0x19, + 87: 0x1A, + 88: 0x1B, + 89: 0x1C, + 90: 0x1D, # a-z - 97: 0x04, 98: 0x05, 99: 0x06, 100: 0x07, 101: 0x08, - 102: 0x09, 103: 0x0A, 104: 0x0B, 105: 0x0C, 106: 0x0D, - 107: 0x0E, 108: 0x0F, 109: 0x10, 110: 0x11, 111: 0x12, - 112: 0x13, 113: 0x14, 114: 0x15, 115: 0x16, 116: 0x17, - 117: 0x18, 118: 0x19, 119: 0x1A, 120: 0x1B, 121: 0x1C, 122: 0x1D, + 97: 0x04, + 98: 0x05, + 99: 0x06, + 100: 0x07, + 101: 0x08, + 102: 0x09, + 103: 0x0A, + 104: 0x0B, + 105: 0x0C, + 106: 0x0D, + 107: 0x0E, + 108: 0x0F, + 109: 0x10, + 110: 0x11, + 111: 0x12, + 112: 0x13, + 113: 0x14, + 114: 0x15, + 115: 0x16, + 116: 0x17, + 117: 0x18, + 118: 0x19, + 119: 0x1A, + 120: 0x1B, + 121: 0x1C, + 122: 0x1D, # Symbols - 32: 0x2C, # Space - 33: 0x1E, # ! - 34: 0x34, # " - 35: 0x20, # # - 36: 0x21, # $ - 37: 0x22, # % - 38: 0x24, # & - 39: 0x34, # ' - 40: 0x26, # ( - 41: 0x27, # ) - 42: 0x25, # * - 43: 0x2E, # + - 44: 0x36, # , - 45: 0x2D, # - - 46: 0x37, # . - 47: 0x38, # / - 9: 0x2B, # Tab - 10: 0x28, # Enter (newline) - 58: 0x33, # : - 59: 0x33, # ; - 60: 0x36, # < - 61: 0x2E, # = - 62: 0x37, # > - 63: 0x38, # ? - 64: 0x1F, # @ - 91: 0x2F, # [ - 92: 0x31, # backslash - 93: 0x30, # ] - 94: 0x23, # ^ - 95: 0x2D, # _ - 96: 0x35, # ` + 32: 0x2C, # Space + 33: 0x1E, # ! + 34: 0x34, # " + 35: 0x20, # # + 36: 0x21, # $ + 37: 0x22, # % + 38: 0x24, # & + 39: 0x34, # ' + 40: 0x26, # ( + 41: 0x27, # ) + 42: 0x25, # * + 43: 0x2E, # + + 44: 0x36, # , + 45: 0x2D, # - + 46: 0x37, # . + 47: 0x38, # / + 9: 0x2B, # Tab + 10: 0x28, # Enter (newline) + 58: 0x33, # : + 59: 0x33, # ; + 60: 0x36, # < + 61: 0x2E, # = + 62: 0x37, # > + 63: 0x38, # ? + 64: 0x1F, # @ + 91: 0x2F, # [ + 92: 0x31, # backslash + 93: 0x30, # ] + 94: 0x23, # ^ + 95: 0x2D, # _ + 96: 0x35, # ` 123: 0x2F, # { 124: 0x31, # | 125: 0x30, # } @@ -210,11 +409,27 @@ } SHIFT_CHARS: set[int] = { - 33, 64, 35, 36, 37, 94, 38, 42, 40, 41, # ! @ # $ % ^ & * ( ) - 95, 43, # _ + - 123, 124, 125, # { | } - 58, 34, 126, # : " ~ - 60, 62, 63, # < > ? + 33, + 64, + 35, + 36, + 37, + 94, + 38, + 42, + 40, + 41, # ! @ # $ % ^ & * ( ) + 95, + 43, # _ + + 123, + 124, + 125, # { | } + 58, + 34, + 126, # : " ~ + 60, + 62, + 63, # < > ? } diff --git a/nanokvm/protocol.py b/nanokvm/protocol.py index 9d4280b..8833cc7 100644 --- a/nanokvm/protocol.py +++ b/nanokvm/protocol.py @@ -59,7 +59,9 @@ def decode(cls, buf: bytes | list[int]) -> CmdPacket: data_len = data[header_idx + 4] if remaining < 5 + data_len + 1: - raise ValueError(f"Packet truncated: need {5 + data_len + 1}, have {remaining}") + raise ValueError( + f"Packet truncated: need {5 + data_len + 1}, have {remaining}" + ) checksum = data[header_idx + 5 + data_len] @@ -69,7 +71,9 @@ def decode(cls, buf: bytes | list[int]) -> CmdPacket: expected &= 0xFF if expected != checksum: - raise ValueError(f"Checksum mismatch: expected 0x{expected:02X}, got 0x{checksum:02X}") + raise ValueError( + f"Checksum mismatch: expected 0x{expected:02X}, got 0x{checksum:02X}" + ) payload = data[header_idx + 5 : header_idx + 5 + data_len] return cls(addr=addr, cmd=cmd, data=payload) diff --git a/nanokvm/video.py b/nanokvm/video.py index d80a658..d2b1cf1 100644 --- a/nanokvm/video.py +++ b/nanokvm/video.py @@ -125,13 +125,15 @@ def list_devices(max_index: int = 10) -> list[dict[str, Any]]: for i in range(max_index): cap = cv2.VideoCapture(i) if cap.isOpened(): - devices.append({ - "index": i, - "width": int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)), - "height": int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)), - "fps": cap.get(cv2.CAP_PROP_FPS), - "backend": cap.getBackendName(), - }) + devices.append( + { + "index": i, + "width": int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)), + "height": int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)), + "fps": cap.get(cv2.CAP_PROP_FPS), + "backend": cap.getBackendName(), + } + ) cap.release() return devices diff --git a/pyproject.toml b/pyproject.toml index 9212fbd..227ffb3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nanokvm" -version = "0.1.1" +version = "0.1.2" description = "Python library for controlling machines via NanoKVM-USB — serial HID + UVC video capture for AI agents" readme = "README.md" requires-python = ">=3.12" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_keyboard.py b/tests/test_keyboard.py new file mode 100644 index 0000000..b736f26 --- /dev/null +++ b/tests/test_keyboard.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +import pytest + +from nanokvm.keyboard import ( + KEYCODE_MAP, + MODIFIER_BITS, + KeyboardReport, + is_modifier, + resolve_key_code, +) + + +class TestResolveKeyCode: + def test_canonical_keycode_passthrough(self) -> None: + assert resolve_key_code("Enter") == "Enter" + assert resolve_key_code("KeyA") == "KeyA" + assert resolve_key_code("F1") == "F1" + assert resolve_key_code("ArrowUp") == "ArrowUp" + + def test_canonical_modifier_passthrough(self) -> None: + assert resolve_key_code("ControlLeft") == "ControlLeft" + assert resolve_key_code("ShiftLeft") == "ShiftLeft" + + def test_friendly_key_aliases(self) -> None: + assert resolve_key_code("enter") == "Enter" + assert resolve_key_code("esc") == "Escape" + assert resolve_key_code("backspace") == "Backspace" + assert resolve_key_code("tab") == "Tab" + assert resolve_key_code("space") == "Space" + assert resolve_key_code("del") == "Delete" + assert resolve_key_code("pgup") == "PageUp" + assert resolve_key_code("up") == "ArrowUp" + + def test_friendly_modifier_aliases(self) -> None: + assert resolve_key_code("ctrl") == "ControlLeft" + assert resolve_key_code("shift") == "ShiftLeft" + assert resolve_key_code("alt") == "AltLeft" + assert resolve_key_code("meta") == "MetaLeft" + assert resolve_key_code("win") == "MetaLeft" + assert resolve_key_code("cmd") == "MetaLeft" + assert resolve_key_code("rctrl") == "ControlRight" + + def test_single_letter_resolution(self) -> None: + assert resolve_key_code("a") == "KeyA" + assert resolve_key_code("z") == "KeyZ" + assert resolve_key_code("A") == "KeyA" + + def test_single_digit_resolution(self) -> None: + assert resolve_key_code("0") == "Digit0" + assert resolve_key_code("5") == "Digit5" + assert resolve_key_code("9") == "Digit9" + + def test_unknown_key_raises(self) -> None: + with pytest.raises(ValueError, match="Unknown key"): + resolve_key_code("nonexistent_key") + + def test_case_insensitive_aliases(self) -> None: + assert resolve_key_code("ENTER") == "Enter" + assert resolve_key_code("Enter") == "Enter" + assert resolve_key_code("CTRL") == "ControlLeft" + + +class TestIsModifier: + def test_modifier_keys(self) -> None: + for mod_code in MODIFIER_BITS: + assert is_modifier(mod_code) is True + + def test_non_modifier_keys(self) -> None: + assert is_modifier("Enter") is False + assert is_modifier("KeyA") is False + assert is_modifier("Space") is False + + +class TestKeyboardReport: + def test_initial_report_is_zeros(self) -> None: + kb = KeyboardReport() + report = kb.reset() + assert report == [0, 0, 0, 0, 0, 0, 0, 0] + + def test_report_length(self) -> None: + kb = KeyboardReport() + report = kb.key_down("KeyA") + assert len(report) == 8 + + def test_regular_key_down(self) -> None: + kb = KeyboardReport() + report = kb.key_down("KeyA") + assert report[0] == 0 # no modifiers + assert report[1] == 0 # reserved + assert report[2] == KEYCODE_MAP["KeyA"] + + def test_regular_key_up(self) -> None: + kb = KeyboardReport() + kb.key_down("KeyA") + report = kb.key_up("KeyA") + assert report == [0, 0, 0, 0, 0, 0, 0, 0] + + def test_modifier_key_down(self) -> None: + kb = KeyboardReport() + report = kb.key_down("ShiftLeft") + assert report[0] == MODIFIER_BITS["ShiftLeft"] + assert report[2:] == [0, 0, 0, 0, 0, 0] + + def test_modifier_key_up(self) -> None: + kb = KeyboardReport() + kb.key_down("ShiftLeft") + report = kb.key_up("ShiftLeft") + assert report[0] == 0 + + def test_multiple_modifiers(self) -> None: + kb = KeyboardReport() + kb.key_down("ControlLeft") + report = kb.key_down("AltLeft") + expected = MODIFIER_BITS["ControlLeft"] | MODIFIER_BITS["AltLeft"] + assert report[0] == expected + + def test_multiple_keys(self) -> None: + kb = KeyboardReport() + kb.key_down("KeyA") + report = kb.key_down("KeyB") + assert report[2] == KEYCODE_MAP["KeyA"] + assert report[3] == KEYCODE_MAP["KeyB"] + + def test_max_six_keys(self) -> None: + kb = KeyboardReport() + keys = ["KeyA", "KeyB", "KeyC", "KeyD", "KeyE", "KeyF"] + for k in keys: + kb.key_down(k) + report = kb.key_down("KeyG") # 7th key, should be ignored + for i, k in enumerate(keys): + assert report[2 + i] == KEYCODE_MAP[k] + + def test_reset_clears_all(self) -> None: + kb = KeyboardReport() + kb.key_down("ShiftLeft") + kb.key_down("KeyA") + report = kb.reset() + assert report == [0, 0, 0, 0, 0, 0, 0, 0] + + +class TestCharToReport: + def test_lowercase_letter(self) -> None: + kb = KeyboardReport() + down, up = kb.char_to_report("a") + assert down[0] == 0 # no shift + assert down[2] == KEYCODE_MAP["KeyA"] + assert up == [0, 0, 0, 0, 0, 0, 0, 0] + + def test_uppercase_letter(self) -> None: + kb = KeyboardReport() + down, up = kb.char_to_report("A") + assert down[0] == MODIFIER_BITS["ShiftLeft"] + assert down[2] == KEYCODE_MAP["KeyA"] + + def test_digit(self) -> None: + kb = KeyboardReport() + down, _up = kb.char_to_report("5") + assert down[0] == 0 + assert down[2] == 0x22 # Digit5 HID code + + def test_space(self) -> None: + kb = KeyboardReport() + down, _up = kb.char_to_report(" ") + assert down[0] == 0 + assert down[2] == 0x2C # Space HID code + + def test_shift_symbol(self) -> None: + kb = KeyboardReport() + down, _up = kb.char_to_report("!") + assert down[0] == MODIFIER_BITS["ShiftLeft"] + + def test_unsupported_char_raises(self) -> None: + kb = KeyboardReport() + with pytest.raises(ValueError, match="Unsupported character"): + kb.char_to_report("\x80") + + def test_report_lengths(self) -> None: + kb = KeyboardReport() + down, up = kb.char_to_report("x") + assert len(down) == 8 + assert len(up) == 8 diff --git a/tests/test_mouse.py b/tests/test_mouse.py new file mode 100644 index 0000000..46a0199 --- /dev/null +++ b/tests/test_mouse.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +import pytest + +from nanokvm.mouse import ( + MAX_ABS_COORD, + MOUSE_MODE_ABSOLUTE, + MOUSE_MODE_RELATIVE, + MouseButton, + build_absolute_report, + build_relative_report, + resolve_button, +) + + +class TestMouseButton: + def test_bit_positions(self) -> None: + assert MouseButton.LEFT == 0x01 + assert MouseButton.RIGHT == 0x02 + assert MouseButton.MIDDLE == 0x04 + assert MouseButton.BACK == 0x08 + assert MouseButton.FORWARD == 0x10 + + +class TestResolveButton: + def test_string_names(self) -> None: + assert resolve_button("left") == MouseButton.LEFT + assert resolve_button("right") == MouseButton.RIGHT + assert resolve_button("middle") == MouseButton.MIDDLE + assert resolve_button("back") == MouseButton.BACK + assert resolve_button("forward") == MouseButton.FORWARD + + def test_case_insensitive(self) -> None: + assert resolve_button("LEFT") == MouseButton.LEFT + assert resolve_button("Right") == MouseButton.RIGHT + + def test_int_passthrough(self) -> None: + assert resolve_button(0x01) == 0x01 + assert resolve_button(42) == 42 + + def test_unknown_raises(self) -> None: + with pytest.raises(ValueError, match="Unknown mouse button"): + resolve_button("invalid") + + +class TestBuildAbsoluteReport: + def test_report_length(self) -> None: + report = build_absolute_report(0.5, 0.5) + assert len(report) == 7 + + def test_mode_byte(self) -> None: + report = build_absolute_report(0.0, 0.0) + assert report[0] == MOUSE_MODE_ABSOLUTE + + def test_origin(self) -> None: + report = build_absolute_report(0.0, 0.0) + assert report[2] == 0 # x_lo + assert report[3] == 0 # x_hi + assert report[4] == 0 # y_lo + assert report[5] == 0 # y_hi + + def test_max_position(self) -> None: + report = build_absolute_report(1.0, 1.0) + x = report[2] | (report[3] << 8) + y = report[4] | (report[5] << 8) + assert x == MAX_ABS_COORD + assert y == MAX_ABS_COORD + + def test_center_position(self) -> None: + report = build_absolute_report(0.5, 0.5) + x = report[2] | (report[3] << 8) + y = report[4] | (report[5] << 8) + assert x == MAX_ABS_COORD // 2 + assert y == MAX_ABS_COORD // 2 + + def test_buttons(self) -> None: + report = build_absolute_report(0.0, 0.0, buttons=MouseButton.LEFT) + assert report[1] == MouseButton.LEFT + + def test_wheel(self) -> None: + report = build_absolute_report(0.0, 0.0, wheel=5) + assert report[6] == 5 + + def test_negative_wheel(self) -> None: + report = build_absolute_report(0.0, 0.0, wheel=-5) + assert report[6] == (-5) & 0xFF + + def test_clamp_below_zero(self) -> None: + report = build_absolute_report(-0.5, -0.5) + assert report[2] == 0 + assert report[3] == 0 + + def test_clamp_above_one(self) -> None: + report = build_absolute_report(1.5, 1.5) + x = report[2] | (report[3] << 8) + y = report[4] | (report[5] << 8) + assert x == MAX_ABS_COORD + assert y == MAX_ABS_COORD + + +class TestBuildRelativeReport: + def test_report_length(self) -> None: + report = build_relative_report() + assert len(report) == 5 + + def test_mode_byte(self) -> None: + report = build_relative_report() + assert report[0] == MOUSE_MODE_RELATIVE + + def test_zero_movement(self) -> None: + report = build_relative_report() + assert report == [MOUSE_MODE_RELATIVE, 0, 0, 0, 0] + + def test_positive_movement(self) -> None: + report = build_relative_report(dx=10, dy=20) + assert report[2] == 10 + assert report[3] == 20 + + def test_negative_movement(self) -> None: + report = build_relative_report(dx=-10, dy=-20) + assert report[2] == (-10) & 0xFF + assert report[3] == (-20) & 0xFF + + def test_clamp_max(self) -> None: + report = build_relative_report(dx=200, dy=200) + assert report[2] == 127 + assert report[3] == 127 + + def test_clamp_min(self) -> None: + report = build_relative_report(dx=-200, dy=-200) + assert report[2] == (-127) & 0xFF + assert report[3] == (-127) & 0xFF + + def test_buttons(self) -> None: + report = build_relative_report(buttons=MouseButton.RIGHT) + assert report[1] == MouseButton.RIGHT + + def test_wheel(self) -> None: + report = build_relative_report(wheel=-3) + assert report[4] == (-3) & 0xFF diff --git a/tests/test_protocol.py b/tests/test_protocol.py new file mode 100644 index 0000000..803d40d --- /dev/null +++ b/tests/test_protocol.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import pytest + +from nanokvm.protocol import CmdEvent, CmdPacket, HEAD1, HEAD2, InfoPacket + + +class TestCmdPacketEncode: + def test_empty_data(self) -> None: + pkt = CmdPacket(addr=0x00, cmd=CmdEvent.GET_INFO, data=[]) + encoded = pkt.encode() + assert encoded[0] == HEAD1 + assert encoded[1] == HEAD2 + assert encoded[2] == 0x00 # addr + assert encoded[3] == CmdEvent.GET_INFO # cmd + assert encoded[4] == 0 # length + checksum = sum(encoded[:-1]) & 0xFF + assert encoded[-1] == checksum + + def test_with_data(self) -> None: + pkt = CmdPacket(addr=0x01, cmd=0x02, data=[0x10, 0x20, 0x30]) + encoded = pkt.encode() + assert encoded[4] == 3 # length + assert list(encoded[5:8]) == [0x10, 0x20, 0x30] + checksum = sum(encoded[:-1]) & 0xFF + assert encoded[-1] == checksum + + def test_header_bytes(self) -> None: + pkt = CmdPacket() + encoded = pkt.encode() + assert encoded[0] == 0x57 + assert encoded[1] == 0xAB + + +class TestCmdPacketDecode: + def test_roundtrip(self) -> None: + original = CmdPacket(addr=0x05, cmd=0x02, data=[0xAA, 0xBB]) + encoded = original.encode() + decoded = CmdPacket.decode(encoded) + assert decoded.addr == original.addr + assert decoded.cmd == original.cmd + assert decoded.data == original.data + + def test_roundtrip_empty_data(self) -> None: + original = CmdPacket(addr=0x00, cmd=CmdEvent.GET_INFO, data=[]) + decoded = CmdPacket.decode(original.encode()) + assert decoded.addr == 0x00 + assert decoded.cmd == CmdEvent.GET_INFO + assert decoded.data == [] + + def test_missing_header_raises(self) -> None: + with pytest.raises(ValueError, match="Cannot find packet header"): + CmdPacket.decode([0x00, 0x00, 0x00, 0x00, 0x00, 0x00]) + + def test_truncated_packet_raises(self) -> None: + with pytest.raises(ValueError, match="Packet too short"): + CmdPacket.decode([HEAD1, HEAD2, 0x00, 0x01]) + + def test_checksum_mismatch_raises(self) -> None: + pkt = CmdPacket(addr=0x00, cmd=0x01, data=[0x10]) + encoded = bytearray(pkt.encode()) + encoded[-1] ^= 0xFF # corrupt checksum + with pytest.raises(ValueError, match="Checksum mismatch"): + CmdPacket.decode(encoded) + + def test_data_length_mismatch_raises(self) -> None: + raw = [HEAD1, HEAD2, 0x00, 0x01, 0x05, 0x10, 0x00] + with pytest.raises(ValueError, match="truncated"): + CmdPacket.decode(raw) + + def test_leading_garbage(self) -> None: + """Decoder should skip bytes before the header.""" + original = CmdPacket(addr=0x00, cmd=0x01, data=[0x42]) + encoded = original.encode() + with_garbage = bytes([0xFF, 0xFE, 0xFD]) + encoded + decoded = CmdPacket.decode(with_garbage) + assert decoded.cmd == 0x01 + assert decoded.data == [0x42] + + +class TestInfoPacket: + def test_basic_parsing(self) -> None: + # version byte 0x30 -> V1.0, connected, no locks + info = InfoPacket.from_data([0x30, 0x01, 0x00]) + assert info.chip_version == "V1.0" + assert info.is_connected is True + assert info.num_lock is False + assert info.caps_lock is False + assert info.scroll_lock is False + + def test_version_parsing(self) -> None: + info = InfoPacket.from_data([0x35, 0x00, 0x00]) + assert info.chip_version == "V1.5" + + def test_disconnected(self) -> None: + info = InfoPacket.from_data([0x30, 0x00, 0x00]) + assert info.is_connected is False + + def test_num_lock(self) -> None: + info = InfoPacket.from_data([0x30, 0x01, 0x01]) + assert info.num_lock is True + assert info.caps_lock is False + assert info.scroll_lock is False + + def test_caps_lock(self) -> None: + info = InfoPacket.from_data([0x30, 0x01, 0x02]) + assert info.num_lock is False + assert info.caps_lock is True + assert info.scroll_lock is False + + def test_scroll_lock(self) -> None: + info = InfoPacket.from_data([0x30, 0x01, 0x04]) + assert info.num_lock is False + assert info.caps_lock is False + assert info.scroll_lock is True + + def test_all_locks(self) -> None: + info = InfoPacket.from_data([0x30, 0x01, 0x07]) + assert info.num_lock is True + assert info.caps_lock is True + assert info.scroll_lock is True + + def test_minimal_data(self) -> None: + """Only version byte provided.""" + info = InfoPacket.from_data([0x30]) + assert info.chip_version == "V1.0" + assert info.is_connected is False + assert info.num_lock is False + + def test_empty_data_raises(self) -> None: + with pytest.raises(ValueError, match="Invalid version byte"): + InfoPacket.from_data([]) + + def test_invalid_version_byte_raises(self) -> None: + with pytest.raises(ValueError, match="Invalid version byte"): + InfoPacket.from_data([0x10]) diff --git a/uv.lock b/uv.lock index fc6aae7..41ef10b 100644 --- a/uv.lock +++ b/uv.lock @@ -22,7 +22,7 @@ wheels = [ [[package]] name = "nanokvm" -version = "0.1.1" +version = "0.1.2" source = { editable = "." } dependencies = [ { name = "numpy" },