vindree

evdev 2nd seat v4.04

Jul 23rd, 2026
10,741
0
Never
2
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Python 21.13 KB | Gaming | 0 0
  1. #!/usr/bin/env python3
  2. """
  3. VTTGrabber v4.04 - Stable USB Input Edition
  4. - Adds scroll wheel zoom and middle-click pan for Foundry VTT
  5. - Atomic message IDs for reliable CDP communication
  6. - Retains all v4.03 functionality: strict USB filtering, modifier keys, device selection
  7. """
  8.  
  9. import asyncio
  10. import evdev
  11. from evdev import ecodes, InputDevice
  12. import json
  13. import os
  14. import signal
  15. import sys
  16. import urllib.request
  17. import websockets
  18. from pathlib import Path
  19.  
  20. CONFIG_FILE = Path.home() / '.vttgrabber.json'
  21. CDP_URL = "ws://localhost:9222/devtools/browser"
  22. SCREEN_WIDTH = 1920
  23. SCREEN_HEIGHT = 1080
  24.  
  25.  
  26. class DeviceScanner:
  27.     """Strict USB device discovery with ASUS hardware blacklist"""
  28.     BUILT_IN_PATTERNS = ['AT Translated', 'Lid Switch', 'Power Button', 'Sleep Button']
  29.    
  30.     def is_forbidden(self, device):
  31.         name = device.name.lower()
  32.         info = device.info
  33.        
  34.         if info.vendor == 0x0b05 and info.product == 0x19b6:
  35.             return True, 'ASUS built-in hardware (Hardcoded block)'
  36.         if 'asus' in name and 'keyboard' in name:
  37.             return True, 'ASUS built-in hardware (Name block)'
  38.         if any(p.lower() in name for p in self.BUILT_IN_PATTERNS):
  39.             return True, 'System built-in'
  40.         return False, ''
  41.  
  42.     def is_usb(self, device):
  43.         return device.phys and 'usb' in device.phys.lower()
  44.  
  45.     def get_all_devices(self):
  46.         devices = {'remotes': [], 'keyboards': [], 'mice': [], 'forbidden': []}
  47.        
  48.         for path in evdev.list_devices():
  49.             try:
  50.                 dev = InputDevice(path)
  51.                 caps = dev.capabilities()
  52.                
  53.                 device_info = {
  54.                     'name': dev.name,
  55.                     'path': path,
  56.                     'usb_path': self._get_usb_path(path)
  57.                 }
  58.                
  59.                 forbidden, reason = self.is_forbidden(dev)
  60.                 if forbidden:
  61.                     device_info['reason'] = reason
  62.                     devices['forbidden'].append(device_info)
  63.                     continue
  64.                    
  65.                 if not self.is_usb(dev):
  66.                     continue
  67.  
  68.                 # Hardware heuristics: mice have REL_X, keyboards have >20 keys
  69.                 keys = caps.get(ecodes.EV_KEY, [])
  70.                 rel_axes = caps.get(ecodes.EV_REL, [])
  71.                 abs_axes = caps.get(ecodes.EV_ABS, [])
  72.                
  73.                 is_mouse = ecodes.REL_X in rel_axes or ecodes.ABS_X in abs_axes
  74.                 is_keyboard = len(keys) > 20
  75.  
  76.                 # Combo devices (wireless dongles) register as both
  77.                 if is_mouse and is_keyboard:
  78.                     devices['remotes'].append(device_info)
  79.                     devices['keyboards'].append(device_info)
  80.                     devices['mice'].append(device_info)
  81.                 elif is_mouse:
  82.                     devices['mice'].append(device_info)
  83.                 elif is_keyboard:
  84.                     devices['keyboards'].append(device_info)
  85.                    
  86.             except (PermissionError, OSError):
  87.                 continue
  88.        
  89.         return devices
  90.    
  91.     def _get_usb_path(self, path):
  92.         try:
  93.             real = os.path.realpath(path)
  94.             parts = real.split('/')
  95.             for i, part in enumerate(parts):
  96.                 if 'usb' in part.lower():
  97.                     return '/'.join(parts[:i+2])
  98.         except:
  99.             pass
  100.         return path
  101.    
  102.     def find_combo_devices(self, devices):
  103.         usb_groups = {}
  104.         for dev in devices:
  105.             usb = dev.get('usb_path', dev['path'])
  106.             usb_groups.setdefault(usb, []).append(dev)
  107.        
  108.         combos = []
  109.         for usb_path, devs in usb_groups.items():
  110.             if len(devs) > 1:
  111.                 combos.append({
  112.                     'name': devs[0]['name'],
  113.                     'path': devs[0]['path'],
  114.                     'usb_path': usb_path,
  115.                     'interfaces': devs
  116.                 })
  117.         return combos
  118.    
  119.     def prompt_device_selection(self, devices_list, device_type, forbidden_list=None, saved_path=None):
  120.         if not devices_list:
  121.             print(f"\nNo USB {device_type} devices found.")
  122.             return None
  123.        
  124.         print(f"\n--- Select USB {device_type.upper()} (Enter to skip) ---")
  125.        
  126.         for i, dev in enumerate(devices_list, 1):
  127.             marker = " [saved]" if dev['path'] == saved_path else ""
  128.             print(f"  [{i}] {dev['name']}{marker}")
  129.             print(f"      {dev['path']}")
  130.        
  131.         if forbidden_list:
  132.             print(f"\n  Forbidden/Blocked Hardware:")
  133.             for dev in forbidden_list:
  134.                 print(f"    - {dev['name']} ({dev['reason']})")
  135.        
  136.         choice = input(f"\nEnter 1-{len(devices_list)} or press Enter to skip: ").strip()
  137.        
  138.         if choice.isdigit():
  139.             idx = int(choice) - 1
  140.             if 0 <= idx < len(devices_list):
  141.                 return devices_list[idx]['path']
  142.        
  143.         if saved_path:
  144.             print(f"Using saved: {saved_path}")
  145.             return saved_path
  146.        
  147.         return None
  148.  
  149.  
  150. class VTTGrabber:
  151.     def __init__(self):
  152.         self.devices = {}
  153.         self.ws = None
  154.         self.target_id = None
  155.         self.running = False
  156.         self.msg_counter = 0  # Atomic message ID counter
  157.        
  158.         self.mouse_x = SCREEN_WIDTH // 2
  159.         self.mouse_y = SCREEN_HEIGHT // 2
  160.         self.scroll_accumulator = 0  # For smooth scroll handling
  161.        
  162.         self.shift_pressed = False
  163.         self.ctrl_pressed = False
  164.         self.alt_pressed = False
  165.         self.meta_pressed = False
  166.        
  167.     def load_saved(self):
  168.         if CONFIG_FILE.exists():
  169.             try:
  170.                 return json.load(open(CONFIG_FILE))
  171.             except:
  172.                 pass
  173.         return {}
  174.    
  175.     def save_config(self, remote, keyboard, mouse):
  176.         try:
  177.             json.dump({'remote': remote, 'keyboard': keyboard, 'mouse': mouse}, open(CONFIG_FILE, 'w'))
  178.         except:
  179.             pass
  180.  
  181.     async def select_and_grab_devices(self):
  182.         scanner = DeviceScanner()
  183.         devices = scanner.get_all_devices()
  184.         saved = self.load_saved()
  185.        
  186.         all_remotes = devices['remotes'].copy()
  187.         all_mice = devices['mice'].copy()
  188.         combos = scanner.find_combo_devices(devices['keyboards'])
  189.         for combo in combos:
  190.             if combo not in all_remotes:
  191.                 all_remotes.append(combo)
  192.        
  193.         remote_path = scanner.prompt_device_selection(all_remotes, 'remote combo/trackpad', saved_path=saved.get('remote'))
  194.         keyboard_path = scanner.prompt_device_selection(devices['keyboards'], 'keyboard', forbidden_list=devices['forbidden'], saved_path=saved.get('keyboard'))
  195.         mouse_path = scanner.prompt_device_selection(all_mice, 'mouse/trackpad', saved_path=saved.get('mouse'))
  196.        
  197.         self.save_config(remote_path, keyboard_path, mouse_path)
  198.        
  199.         paths = {'remote': remote_path, 'keyboard': keyboard_path, 'mouse': mouse_path}
  200.         for name, path in paths.items():
  201.             if path:
  202.                 try:
  203.                     dev = InputDevice(path)
  204.                     dev.grab()
  205.                     self.devices[name] = dev
  206.                     print(f"Opened and grabbed {name}: {dev.name}")
  207.                 except Exception as e:
  208.                     print(f"Failed to open {name}: {e}. Run with sudo.")
  209.         return len(self.devices) > 0
  210.  
  211.     async def find_chromium_target(self):
  212.         try:
  213.             req = urllib.request.Request("http://localhost:9222/json", headers={"Host": "localhost:9222"})
  214.             with urllib.request.urlopen(req, timeout=5) as response:
  215.                 targets = json.loads(response.read().decode())
  216.             for target in targets:
  217.                 if target["type"] == "page":
  218.                     self.target_id = target["id"]
  219.                     print(f"Found target: {target.get('title', 'Unknown')}")
  220.                     return target["webSocketDebuggerUrl"]
  221.         except Exception as e:
  222.             print(f"Error finding Chromium target: {e}")
  223.             return None
  224.  
  225.     async def connect_cdp(self):
  226.         ws_url = await self.find_chromium_target()
  227.         if not ws_url:
  228.             raise ConnectionError("No Chromium target found. Is Chrome running with --remote-debugging-port=9222?")
  229.         self.ws = await websockets.connect(ws_url, ping_interval=None)
  230.         await self.send_cdp("Input.enable", {})
  231.         await self.send_cdp("Runtime.enable", {})
  232.         asyncio.create_task(self._drain_ws())
  233.  
  234.     async def _drain_ws(self):
  235.         """Background task to prevent WebSocket buffer overflow"""
  236.         try:
  237.             while self.running and self.ws:
  238.                 try:
  239.                     await asyncio.wait_for(self.ws.recv(), timeout=0.1)
  240.                 except asyncio.TimeoutError:
  241.                     continue
  242.                 except Exception:
  243.                     break
  244.         except Exception:
  245.             pass
  246.  
  247.     async def send_cdp(self, method, params):
  248.         """Send CDP command with atomic message ID"""
  249.         if not self.ws:
  250.             return
  251.         self.msg_counter += 1
  252.         msg = {"id": self.msg_counter, "method": method, "params": params}
  253.         try:
  254.             await self.ws.send(json.dumps(msg))
  255.         except Exception:
  256.             pass
  257.  
  258.     async def inject_cursor(self):
  259.         """Inject synthetic cursor for visibility"""
  260.         cursor_script = """
  261.        (function() {
  262.            const existing = document.getElementById('vtt-cursor');
  263.            if (existing) existing.remove();
  264.            const c = document.createElement('div');
  265.            c.id = 'vtt-cursor';
  266.            c.style.cssText = `position: fixed; width: 24px; height: 24px; background: radial-gradient(circle, rgba(255,0,85,0.9) 0%, rgba(255,0,85,0.6) 40%, rgba(255,0,85,0) 70%); border: 3px solid #ff0055; border-radius: 50%; pointer-events: none; z-index: 2147483647; left: 0; top: 0; box-shadow: 0 0 10px #ff0055, 0 0 20px #ff0055; transition: transform 0.05s ease-out;`;
  267.            document.body.appendChild(c);
  268.            window.vttCursor = c;
  269.            window.updateVTTCursor = function(x, y) { if (window.vttCursor) window.vttCursor.style.transform = `translate(${x}px, ${y}px)`; };
  270.        })();
  271.        """
  272.         await self.send_cdp("Runtime.evaluate", {"expression": cursor_script, "awaitPromise": False})
  273.  
  274.     async def _get_modifiers(self):
  275.         """Calculate modifier bitmask for CDP"""
  276.         modifiers = 0
  277.         if self.alt_pressed:
  278.             modifiers |= 1
  279.         if self.ctrl_pressed:
  280.             modifiers |= 2
  281.         if self.meta_pressed:
  282.             modifiers |= 4
  283.         if self.shift_pressed:
  284.             modifiers |= 8
  285.         return modifiers
  286.  
  287.     # =========================================================================
  288.     # MOUSE HANDLING - Added scroll wheel and middle-click
  289.     # =========================================================================
  290.     async def route_mouse_device(self, device):
  291.         """Handle mouse movement, buttons, and scroll"""
  292.         async for event in device.async_read_loop():
  293.             if not self.running:
  294.                 break
  295.            
  296.             # Mouse movement
  297.             if event.type == ecodes.EV_REL:
  298.                 if event.code == ecodes.REL_X:
  299.                     self.mouse_x = max(0, min(SCREEN_WIDTH - 1, self.mouse_x + event.value))
  300.                 elif event.code == ecodes.REL_Y:
  301.                     self.mouse_y = max(0, min(SCREEN_HEIGHT - 1, self.mouse_y + event.value))
  302.                
  303.                 await self.send_cdp("Input.dispatchMouseEvent", {
  304.                     "type": "mouseMoved",
  305.                     "x": self.mouse_x,
  306.                     "y": self.mouse_y,
  307.                     "modifiers": await self._get_modifiers()
  308.                 })
  309.                
  310.                 # Update synthetic cursor position
  311.                 await self.send_cdp("Runtime.evaluate", {
  312.                     "expression": f"if(window.updateVTTCursor) window.updateVTTCursor({self.mouse_x},{self.mouse_y});",
  313.                     "awaitPromise": False
  314.                 })
  315.            
  316.             # Scroll wheel (NEW in v4.04)
  317.             elif event.type == ecodes.EV_REL and event.code == ecodes.REL_WHEEL:
  318.                 self.scroll_accumulator += event.value
  319.                 if abs(self.scroll_accumulator) >= 1:
  320.                     await self.send_cdp("Input.dispatchMouseEvent", {
  321.                         "type": "mouseWheel",
  322.                         "x": self.mouse_x,
  323.                         "y": self.mouse_y,
  324.                         "deltaX": 0,
  325.                         "deltaY": int(self.scroll_accumulator * -50),  # Negative for natural scroll
  326.                         "modifiers": await self._get_modifiers()
  327.                     })
  328.                     self.scroll_accumulator = 0
  329.            
  330.             # Mouse buttons (NEW: added middle click 274)
  331.             elif event.type == ecodes.EV_KEY:
  332.                 if event.code in (272, 273, 274):  # 272=Left, 273=Right, 274=Middle
  333.                     button_map = {272: "left", 273: "right", 274: "middle"}
  334.                     button = button_map[event.code]
  335.                     action = "mousePressed" if event.value == 1 else "mouseReleased"
  336.                    
  337.                     await self.send_cdp("Input.dispatchMouseEvent", {
  338.                         "type": action,
  339.                         "x": self.mouse_x,
  340.                         "y": self.mouse_y,
  341.                         "button": button,
  342.                         "clickCount": 1,
  343.                         "modifiers": await self._get_modifiers()
  344.                     })
  345.  
  346.     # =========================================================================
  347.     # KEYBOARD HANDLING - Unchanged from v4.03 (proven working)
  348.     # =========================================================================
  349.     def _parse_dynamic_key(self, keycode):
  350.         """Extended dynamic parser for VTT functional keys"""
  351.         key_name = ecodes.KEY.get(keycode)
  352.         if not key_name:
  353.             return None, None, None
  354.         if isinstance(key_name, list):
  355.             key_name = key_name[0]
  356.        
  357.         base = key_name.replace('KEY_', '')
  358.        
  359.         # Standard Alphanumeric
  360.         if len(base) == 1 and base.isalpha():
  361.             return (base if self.shift_pressed else base.lower(), f"Key{base}", base if self.shift_pressed else base.lower())
  362.         if len(base) == 1 and base.isdigit():
  363.             symbols = {'1':'!', '2':'@', '3':'#', '4':'$', '5':'%', '6':'^', '7':'&', '8':'*', '9':'(', '0':')'}
  364.             return (symbols[base] if self.shift_pressed else base, f"Digit{base}", symbols[base] if self.shift_pressed else base)
  365.            
  366.         # Expanded Functional Mapping
  367.         specials = {
  368.             'SPACE': (' ', 'Space', ' '),
  369.             'ENTER': ('\r', 'Enter', None),
  370.             'KPENTER': ('\r', 'NumpadEnter', None),
  371.             'BACKSPACE': ('Backspace', 'Backspace', None),
  372.             'DELETE': ('Delete', 'Delete', None),
  373.             'PAGEUP': ('PageUp', 'PageUp', None),
  374.             'PAGEDOWN': ('PageDown', 'PageDown', None),
  375.             'UP': ('ArrowUp', 'ArrowUp', None),
  376.             'DOWN': ('ArrowDown', 'ArrowDown', None),
  377.             'LEFT': ('ArrowLeft', 'ArrowLeft', None),
  378.             'RIGHT': ('ArrowRight', 'ArrowRight', None),
  379.             'MINUS': ('_' if self.shift_pressed else '-', 'Minus', '_' if self.shift_pressed else '-'),
  380.             'EQUAL': ('+' if self.shift_pressed else '=', 'Equal', '+' if self.shift_pressed else '='),
  381.             'SLASH': ('?' if self.shift_pressed else '/', 'Slash', '?' if self.shift_pressed else '/'),
  382.             'DOT': ('>' if self.shift_pressed else '.', 'Period', '>' if self.shift_pressed else '.'),
  383.             'COMMA': ('<' if self.shift_pressed else ',', 'Comma', '<' if self.shift_pressed else ','),
  384.             'ESC': ('Escape', 'Escape', None)
  385.         }
  386.         return specials.get(base, (None, None, None))
  387.  
  388.     async def _send_modifier_keys(self, keycode, value):
  389.         """Send modifier key state changes"""
  390.         mod_map = {
  391.             ecodes.KEY_LEFTSHIFT: ("Shift", "ShiftLeft"),
  392.             ecodes.KEY_RIGHTSHIFT: ("Shift", "ShiftRight"),
  393.             ecodes.KEY_LEFTCTRL: ("Control", "ControlLeft"),
  394.             ecodes.KEY_RIGHTCTRL: ("Control", "ControlRight"),
  395.             ecodes.KEY_LEFTALT: ("Alt", "AltLeft"),
  396.             ecodes.KEY_RIGHTALT: ("Alt", "AltRight"),
  397.         }
  398.         if keycode in mod_map:
  399.             key, code = mod_map[keycode]
  400.             await self.send_cdp("Input.dispatchKeyEvent", {
  401.                 "type": "keyDown" if value > 0 else "keyUp",
  402.                 "key": key,
  403.                 "code": code,
  404.                 "modifiers": await self._get_modifiers()
  405.             })
  406.  
  407.     async def route_keyboard_device(self, device):
  408.         """Handle keyboard input with strict event type checking"""
  409.         async for event in device.async_read_loop():
  410.             if not self.running or event.type != ecodes.EV_KEY:
  411.                 continue
  412.                
  413.             keycode, value = event.code, event.value
  414.            
  415.             # Update modifier state
  416.             if keycode in (ecodes.KEY_LEFTSHIFT, ecodes.KEY_RIGHTSHIFT):
  417.                 self.shift_pressed = value > 0
  418.                 await self._send_modifier_keys(keycode, value)
  419.                 continue
  420.             elif keycode in (ecodes.KEY_LEFTCTRL, ecodes.KEY_RIGHTCTRL):
  421.                 self.ctrl_pressed = value > 0
  422.                 await self._send_modifier_keys(keycode, value)
  423.                 continue
  424.             elif keycode in (ecodes.KEY_LEFTALT, ecodes.KEY_RIGHTALT):
  425.                 self.alt_pressed = value > 0
  426.                 await self._send_modifier_keys(keycode, value)
  427.                 continue
  428.            
  429.             # Special key handling
  430.             if keycode == ecodes.KEY_TAB and value == 1:
  431.                 await self.send_cdp("Runtime.evaluate", {
  432.                     "expression": "const evt = new KeyboardEvent('keydown', {key: 'Tab', code: 'Tab', keyCode: 9, which: 9, bubbles: true}); (document.activeElement || document.body).dispatchEvent(evt);"
  433.                 })
  434.                 continue
  435.             if keycode == ecodes.KEY_ESC and value == 1:
  436.                 await self.send_cdp("Runtime.evaluate", {
  437.                     "expression": "document.querySelectorAll('.window-header .close, #chat-controls .close').forEach(b => b.click()); const c = document.querySelector('canvas#board'); if(c)c.focus(); document.dispatchEvent(new KeyboardEvent('keydown', {key: 'Escape', code: 'Escape', keyCode: 27, which: 27, bubbles: true}));"
  438.                 })
  439.                 continue
  440.            
  441.             # Regular key handling
  442.             display_key, code, text_char = self._parse_dynamic_key(keycode)
  443.             if not display_key:
  444.                 continue
  445.            
  446.             if value in (1, 2):  # Key down or repeat
  447.                 await self.send_cdp("Input.dispatchKeyEvent", {
  448.                     "type": "keyDown",
  449.                     "key": display_key,
  450.                     "code": code,
  451.                     "modifiers": await self._get_modifiers()
  452.                 })
  453.                 if value == 1 and text_char:  # Only send char on initial press
  454.                     await self.send_cdp("Input.dispatchKeyEvent", {
  455.                         "type": "char",
  456.                         "text": text_char,
  457.                         "modifiers": await self._get_modifiers()
  458.                     })
  459.             elif value == 0:  # Key up
  460.                 await self.send_cdp("Input.dispatchKeyEvent", {
  461.                     "type": "keyUp",
  462.                     "key": display_key,
  463.                     "code": code,
  464.                     "modifiers": await self._get_modifiers()
  465.                 })
  466.  
  467.     # =========================================================================
  468.     # MAIN
  469.     # =========================================================================
  470.     async def run(self):
  471.         self.running = True
  472.        
  473.         if not await self.select_and_grab_devices():
  474.             print("No USB devices selected. Exiting.")
  475.             return
  476.            
  477.         print("\nConnecting to Chromium...")
  478.         await self.connect_cdp()
  479.         await self.inject_cursor()
  480.        
  481.         # Create tasks for all grabbed devices
  482.         tasks = []
  483.         for name, device in self.devices.items():
  484.             if 'mouse' in name or 'remote' in name:
  485.                 tasks.append(asyncio.create_task(self.route_mouse_device(device)))
  486.             if 'keyboard' in name or 'remote' in name:
  487.                 tasks.append(asyncio.create_task(self.route_keyboard_device(device)))
  488.        
  489.         await asyncio.gather(*tasks)
  490.  
  491.     def cleanup(self):
  492.         """Release all grabbed devices"""
  493.         self.running = False
  494.         for name, device in self.devices.items():
  495.             try:
  496.                 device.ungrab()
  497.                 print(f"Released {name}")
  498.             except:
  499.                 pass
  500.  
  501.  
  502. if __name__ == "__main__":
  503.     grabber = VTTGrabber()
  504.    
  505.     # Signal handling for clean shutdown
  506.     def signal_handler(signum, frame):
  507.         print(f"\nReceived signal {signum}, shutting down...")
  508.         grabber.cleanup()
  509.         sys.exit(0)
  510.    
  511.     signal.signal(signal.SIGINT, signal_handler)
  512.     signal.signal(signal.SIGTERM, signal_handler)
  513.    
  514.     try:
  515.         asyncio.run(grabber.run())
  516.     except (KeyboardInterrupt, Exception) as e:
  517.         print(f"Error: {e}")
  518.         grabber.cleanup()
Tags: foundryvtt
Advertisement