1# Copyright 2015 The Chromium OS Authors. All rights reserved. 2# Use of this source code is governed by a BSD-style license that can be 3# found in the LICENSE file. 4 5import glob 6import logging 7import os 8import subprocess 9import tempfile 10import time 11 12from autotest_lib.client.bin import utils 13from autotest_lib.client.common_lib import error 14 15 16class Device(object): 17 """Information about a specific input device.""" 18 def __init__(self, input_type): 19 self.input_type = input_type # e.g. 'touchpad' 20 self.emulated = False # Whether device is real or not 21 self.emulation_process = None # Process of running emulation 22 self.name = 'unknown' # e.g. 'Atmel maXTouch Touchpad' 23 self.fw_id = None # e.g. '6.0' 24 self.hw_id = None # e.g. '90.0' 25 self.node = None # e.g. '/dev/input/event4' 26 self.device_dir = None # e.g. '/sys/class/input/event4/device/device' 27 28 def __str__(self): 29 s = '%s:' % self.input_type 30 s += '\n Name: %s' % self.name 31 s += '\n Node: %s' % self.node 32 s += '\n hw_id: %s' % self.hw_id 33 s += '\n fw_id: %s' % self.fw_id 34 s += '\n Emulated: %s' % self.emulated 35 return s 36 37 38class InputPlayback(object): 39 """ 40 Provides an interface for playback and emulating peripherals via evemu-*. 41 42 Example use: player = InputPlayback() 43 player.emulate(property_file=path_to_file) 44 player.find_connected_inputs() 45 player.playback(path_to_file) 46 player.blocking_playback(path_to_file) 47 player.close() 48 49 """ 50 51 _DEFAULT_PROPERTY_FILES = {'mouse': 'mouse.prop', 52 'keyboard': 'keyboard.prop'} 53 _PLAYBACK_COMMAND = 'evemu-play --insert-slot0 %s < %s' 54 55 # Define the overhead (500 ms) elapsed for launching evemu-play and the 56 # latency from event injection to the first event read by Chrome Input 57 # thread. 58 _PLAYBACK_OVERHEAD_LATENCY = 0.5 59 60 # Define a keyboard as anything with any keys #2 to #248 inclusive, 61 # as defined in the linux input header. This definition includes things 62 # like the power button, so reserve the "keyboard" label for things with 63 # letters/numbers and define the rest as "other_keyboard". 64 _MINIMAL_KEYBOARD_KEYS = ['1', 'Q', 'SPACE'] 65 _KEYBOARD_KEYS = [ 66 '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', 'MINUS', 'EQUAL', 67 'BACKSPACE', 'TAB', 'Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 68 'P', 'LEFTBRACE', 'RIGHTBRACE', 'ENTER', 'LEFTCTRL', 'A', 'S', 'D', 69 'F', 'G', 'H', 'J', 'K', 'L', 'SEMICOLON', 'APOSTROPHE', 'GRAVE', 70 'LEFTSHIFT', 'BACKSLASH', 'Z', 'X', 'C', 'V', 'B', 'N', 'M', 71 'COMMA', 'DOT', 'SLASH', 'RIGHTSHIFT', 'KPASTERISK', 'LEFTALT', 72 'SPACE', 'CAPSLOCK', 'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 73 'F9', 'F10', 'NUMLOCK', 'SCROLLLOCK', 'KP7', 'KP8', 'KP9', 74 'KPMINUS', 'KP4', 'KP5', 'KP6', 'KPPLUS', 'KP1', 'KP2', 'KP3', 75 'KP0', 'KPDOT', 'ZENKAKUHANKAKU', '102ND', 'F11', 'F12', 'RO', 76 'KATAKANA', 'HIRAGANA', 'HENKAN', 'KATAKANAHIRAGANA', 'MUHENKAN', 77 'KPJPCOMMA', 'KPENTER', 'RIGHTCTRL', 'KPSLASH', 'SYSRQ', 'RIGHTALT', 78 'LINEFEED', 'HOME', 'UP', 'PAGEUP', 'LEFT', 'RIGHT', 'END', 'DOWN', 79 'PAGEDOWN', 'INSERT', 'DELETE', 'MACRO', 'MUTE', 'VOLUMEDOWN', 80 'VOLUMEUP', 'POWER', 'KPEQUAL', 'KPPLUSMINUS', 'PAUSE', 'SCALE', 81 'KPCOMMA', 'HANGEUL', 'HANGUEL', 'HANJA', 'YEN', 'LEFTMETA', 82 'RIGHTMETA', 'COMPOSE', 'STOP', 'AGAIN', 'PROPS', 'UNDO', 'FRONT', 83 'COPY', 'OPEN', 'PASTE', 'FIND', 'CUT', 'HELP', 'MENU', 'CALC', 84 'SETUP', 'WAKEUP', 'FILE', 'SENDFILE', 'DELETEFILE', 'XFER', 85 'PROG1', 'PROG2', 'WWW', 'MSDOS', 'COFFEE', 'SCREENLOCK', 86 'DIRECTION', 'CYCLEWINDOWS', 'MAIL', 'BOOKMARKS', 'COMPUTER', 87 'BACK', 'FORWARD', 'CLOSECD', 'EJECTCD', 'EJECTCLOSECD', 'NEXTSONG', 88 'PLAYPAUSE', 'PREVIOUSSONG', 'STOPCD', 'RECORD', 'REWIND', 'PHONE', 89 'ISO', 'CONFIG', 'HOMEPAGE', 'REFRESH', 'EXIT', 'MOVE', 'EDIT', 90 'SCROLLUP', 'SCROLLDOWN', 'KPLEFTPAREN', 'KPRIGHTPAREN', 'NEW', 91 'REDO', 'F13', 'F14', 'F15', 'F16', 'F17', 'F18', 'F19', 'F20', 92 'F21', 'F22', 'F23', 'F24', 'PLAYCD', 'PAUSECD', 'PROG3', 'PROG4', 93 'DASHBOARD', 'SUSPEND', 'CLOSE', 'PLAY', 'FASTFORWARD', 'BASSBOOST', 94 'PRINT', 'HP', 'CAMERA', 'SOUND', 'QUESTION', 'EMAIL', 'CHAT', 95 'SEARCH', 'CONNECT', 'FINANCE', 'SPORT', 'SHOP', 'ALTERASE', 96 'CANCEL', 'BRIGHTNESSDOWN', 'BRIGHTNESSUP', 'MEDIA', 97 'SWITCHVIDEOMODE', 'KBDILLUMTOGGLE', 'KBDILLUMDOWN', 'KBDILLUMUP', 98 'SEND', 'REPLY', 'FORWARDMAIL', 'SAVE', 'DOCUMENTS', 'BATTERY', 99 'BLUETOOTH', 'WLAN', 'UWB', 'UNKNOWN', 'VIDEO_NEXT', 'VIDEO_PREV', 100 'BRIGHTNESS_CYCLE', 'BRIGHTNESS_AUTO', 'BRIGHTNESS_ZERO', 101 'DISPLAY_OFF', 'WWAN', 'WIMAX', 'RFKILL', 'MICMUTE'] 102 103 104 def __init__(self): 105 self.devices = {} 106 self._emulated_device = None 107 108 109 def has(self, input_type): 110 """Return True/False if device has a input of given type. 111 112 @param input_type: string of type, e.g. 'touchpad' 113 114 """ 115 return input_type in self.devices 116 117 118 def _get_input_events(self): 119 """Return a list of all input event nodes.""" 120 return glob.glob('/dev/input/event*') 121 122 123 def emulate(self, input_type='mouse', property_file=None): 124 """ 125 Emulate the given input (or default for type) with evemu-device. 126 127 Emulating more than one of the same device type will only allow playback 128 on the last one emulated. The name of the last-emulated device is 129 noted to be sure this is the case. 130 131 Property files are made with the evemu-describe command, 132 e.g. 'evemu-describe /dev/input/event12 > property_file'. 133 134 @param input_type: 'mouse' or 'keyboard' to use default property files. 135 Need not be specified if supplying own file. 136 @param property_file: Property file of device to be emulated. Generate 137 with 'evemu-describe' command on test image. 138 139 """ 140 new_device = Device(input_type) 141 new_device.emulated = True 142 143 # Checks for any previous emulated device and kills the process 144 self.close() 145 146 if not property_file: 147 if input_type not in self._DEFAULT_PROPERTY_FILES: 148 raise error.TestError('Please supply a property file for input ' 149 'type %s' % input_type) 150 current_dir = os.path.dirname(os.path.realpath(__file__)) 151 property_file = os.path.join( 152 current_dir, self._DEFAULT_PROPERTY_FILES[input_type]) 153 if not os.path.isfile(property_file): 154 raise error.TestError('Property file %s not found!' % property_file) 155 156 with open(property_file) as fh: 157 name_line = fh.readline() # Format "N: NAMEOFDEVICE" 158 new_device.name = name_line[3:-1] 159 160 logging.info('Emulating %s %s (%s).', input_type, new_device.name, 161 property_file) 162 num_events_before = len(self._get_input_events()) 163 new_device.emulation_process = subprocess.Popen( 164 ['evemu-device', property_file], stdout=subprocess.PIPE) 165 166 self._emulated_device = new_device 167 168 # Ensure there are more input events than there were before. 169 try: 170 expected = num_events_before + 1 171 exception = error.TestError('Error emulating %s!' % input_type) 172 utils.poll_for_condition( 173 lambda: len(self._get_input_events()) == expected, 174 exception=exception) 175 except error.TestError as e: 176 self.close() 177 raise e 178 179 180 def _find_device_properties(self, device): 181 """Return string of properties for given node. 182 183 @return: string of properties. 184 185 """ 186 with tempfile.NamedTemporaryFile() as temp_file: 187 filename = temp_file.name 188 evtest_process = subprocess.Popen(['evtest', device], 189 stdout=temp_file) 190 191 def find_exit(): 192 """Polling function for end of output.""" 193 interrupt_cmd = 'grep "interrupt to exit" %s | wc -l' % filename 194 line_count = utils.run(interrupt_cmd).stdout.strip() 195 return line_count != '0' 196 197 utils.poll_for_condition(find_exit) 198 evtest_process.kill() 199 temp_file.seek(0) 200 props = temp_file.read() 201 return props 202 203 204 def _determine_input_type(self, props): 205 """Find input type (if any) from a string of properties. 206 207 @return: string of type, or None 208 209 """ 210 if props.find('REL_X') >= 0 and props.find('REL_Y') >= 0: 211 if (props.find('ABS_MT_POSITION_X') >= 0 and 212 props.find('ABS_MT_POSITION_Y') >= 0): 213 return 'multitouch_mouse' 214 else: 215 return 'mouse' 216 if props.find('ABS_X') >= 0 and props.find('ABS_Y') >= 0: 217 if (props.find('BTN_STYLUS') >= 0 or 218 props.find('BTN_STYLUS2') >= 0 or 219 props.find('BTN_TOOL_PEN') >= 0): 220 return 'stylus' 221 if (props.find('ABS_PRESSURE') >= 0 or 222 props.find('BTN_TOUCH') >= 0): 223 if (props.find('BTN_LEFT') >= 0 or 224 props.find('BTN_MIDDLE') >= 0 or 225 props.find('BTN_RIGHT') >= 0 or 226 props.find('BTN_TOOL_FINGER') >= 0): 227 return 'touchpad' 228 else: 229 return 'touchscreen' 230 if props.find('BTN_LEFT') >= 0: 231 return 'touchscreen' 232 if props.find('KEY_') >= 0: 233 for key in self._MINIMAL_KEYBOARD_KEYS: 234 if props.find('KEY_%s' % key) >= 0: 235 return 'keyboard' 236 for key in self._KEYBOARD_KEYS: 237 if props.find('KEY_%s' % key) >= 0: 238 return 'other_keyboard' 239 return 240 241 242 def _get_contents_of_file(self, filepath): 243 """Return the contents of the given file. 244 245 @param filepath: string of path to file 246 247 @returns: contents of file. Assumes file exists. 248 249 """ 250 return utils.run('cat %s' % filepath).stdout.strip() 251 252 253 def _find_input_name(self, device_dir, name=None): 254 """Find the associated input* name for the given device directory. 255 256 E.g. given '/dev/input/event4', return 'input3'. 257 258 @param device_dir: the device directory. 259 @param name: the device name. 260 261 262 @returns: string of the associated input name. 263 264 """ 265 input_names = glob.glob(os.path.join(device_dir, 'input', 'input*')) 266 for input_name in input_names: 267 name_path = os.path.join(input_name, 'name') 268 if not os.path.exists(name_path): 269 continue 270 if name == self._get_contents_of_file(name_path): 271 return os.path.basename(input_name) 272 # Raise if name could not be matched. 273 logging.error('Input names found(%s): %s', device_dir, input_names) 274 raise error.TestError('Could not match input* to this device!') 275 276 277 def _find_device_ids_for_styluses(self, device_dir, name=None): 278 """Find the fw_id and hw_id for the stylus in the given directory. 279 280 @param device_dir: the device directory. 281 @param name: the device name. 282 283 @returns: firmware id, hardware id for this device. 284 285 """ 286 hw_id = 'wacom' # Wacom styluses don't actually have hwids. 287 fw_id = None 288 289 # Find fw_id for wacom styluses via wacom_flash command. Arguments 290 # to this command are wacom_flash (dummy placeholder arg) -a (i2c name) 291 # Find i2c name if any /dev/i2c-* link to this device's input event. 292 input_name = self._find_input_name(device_dir, name) 293 i2c_paths = glob.glob('/dev/i2c-*') 294 for i2c_path in i2c_paths: 295 class_folder = i2c_path.replace('dev', 'sys/class/i2c-adapter') 296 input_folder_path = os.path.join(class_folder, '*', '*', 297 'input', input_name) 298 contents_of_input_folder = glob.glob(input_folder_path) 299 if len(contents_of_input_folder) != 0: 300 i2c_name = i2c_path[len('/dev/'):] 301 cmd = 'wacom_flash dummy -a %s' % i2c_name 302 # Do not throw an exception if wacom_flash does not exist. 303 result = utils.run(cmd, ignore_status=True) 304 if result.exit_status == 0: 305 fw_id = result.stdout.split()[-1] 306 break 307 308 if fw_id == '': 309 fw_id = None 310 return fw_id, hw_id 311 312 313 def _find_device_ids(self, device_dir, input_type, name): 314 """Find the fw_id and hw_id for the given device directory. 315 316 Finding fw_id and hw_id applicable only for touchpads, touchscreens, 317 and styluses. 318 319 @param device_dir: the device directory. 320 @param input_type: string of input type. 321 @param name: string of input name. 322 323 @returns: firmware id, hardware id 324 325 """ 326 fw_id, hw_id = None, None 327 328 if not device_dir or input_type not in ['touchpad', 'touchscreen', 329 'stylus']: 330 return fw_id, hw_id 331 if input_type == 'stylus': 332 return self._find_device_ids_for_styluses(device_dir, name) 333 334 # Touch devices with custom drivers usually save this info as a file. 335 fw_filenames = ['fw_version', 'firmware_version', 'firmware_id'] 336 for fw_filename in fw_filenames: 337 fw_path = os.path.join(device_dir, fw_filename) 338 if os.path.exists(fw_path): 339 if fw_id: 340 logging.warning('Found new potential fw_id when previous ' 341 'value was %s!', fw_id) 342 fw_id = self._get_contents_of_file(fw_path) 343 344 hw_filenames = ['hw_version', 'product_id', 'board_id'] 345 for hw_filename in hw_filenames: 346 hw_path = os.path.join(device_dir, hw_filename) 347 if os.path.exists(hw_path): 348 if hw_id: 349 logging.warning('Found new potential hw_id when previous ' 350 'value was %s!', hw_id) 351 hw_id = self._get_contents_of_file(hw_path) 352 353 # Hw_ids for Weida and 2nd gen Synaptics are different. 354 if not hw_id: 355 id_folder = os.path.abspath(os.path.join(device_dir, '..', 'id')) 356 product_path = os.path.join(id_folder, 'product') 357 vendor_path = os.path.join(id_folder, 'vendor') 358 359 if os.path.isfile(product_path): 360 product = self._get_contents_of_file(product_path) 361 if name.startswith('WD'): # Weida ts, e.g. sumo 362 if os.path.isfile(vendor_path): 363 vendor = self._get_contents_of_file(vendor_path) 364 hw_id = vendor + product 365 else: # Synaptics tp or ts, e.g. heli, lulu, setzer 366 hw_id = product 367 368 if not fw_id: 369 # Fw_ids for 2nd gen Synaptics can only be found via rmi4update. 370 # See if any /dev/hidraw* link to this device's input event. 371 input_name = self._find_input_name(device_dir, name) 372 hidraws = glob.glob('/dev/hidraw*') 373 for hidraw in hidraws: 374 class_folder = hidraw.replace('dev', 'sys/class/hidraw') 375 input_folder_path = os.path.join(class_folder, 'device', 376 'input', input_name) 377 if os.path.exists(input_folder_path): 378 fw_id = utils.run('rmi4update -p -d %s' % hidraw, 379 ignore_status=True).stdout.strip() 380 if fw_id == '': 381 fw_id = None 382 break 383 384 return fw_id, hw_id 385 386 387 def find_connected_inputs(self): 388 """Determine the nodes of all present input devices, if any. 389 390 Cycle through all possible /dev/input/event* and find which ones 391 are touchpads, touchscreens, mice, keyboards, etc. 392 These nodes can be used for playback later. 393 If the type of input is already emulated, prefer that device. Otherwise, 394 prefer the last node found of that type (e.g. for multiple touchpads). 395 Record the found devices in self.devices. 396 397 """ 398 self.devices = {} # Discard any previously seen nodes. 399 400 input_events = self._get_input_events() 401 for event in input_events: 402 properties = self._find_device_properties(event) 403 input_type = self._determine_input_type(properties) 404 if input_type: 405 new_device = Device(input_type) 406 new_device.node = event 407 408 class_folder = event.replace('dev', 'sys/class') 409 name_file = os.path.join(class_folder, 'device', 'name') 410 if os.path.isfile(name_file): 411 name = self._get_contents_of_file(name_file) 412 logging.info('Found %s: %s at %s.', input_type, name, event) 413 414 # If a particular device is expected, make sure name matches. 415 if (self._emulated_device and 416 self._emulated_device.input_type == input_type): 417 if self._emulated_device.name != name: 418 continue 419 else: 420 new_device.emulated = True 421 process = self._emulated_device.emulation_process 422 new_device.emulation_process = process 423 new_device.name = name 424 425 # Find the devices folder containing power info 426 # e.g. /sys/class/event4/device/device 427 # Search that folder for hwid and fwid 428 device_dir = os.path.join(class_folder, 'device', 'device') 429 if os.path.exists(device_dir): 430 new_device.device_dir = device_dir 431 new_device.fw_id, new_device.hw_id = self._find_device_ids( 432 device_dir, input_type, new_device.name) 433 434 if new_device.emulated: 435 self._emulated_device = new_device 436 437 self.devices[input_type] = new_device 438 logging.debug(self.devices[input_type]) 439 440 441 def playback(self, filepath, input_type='touchpad'): 442 """Playback a given input file. 443 444 Create input file using evemu-record. 445 E.g. 'evemu-record $NODE -1 > $FILENAME' 446 447 @param filepath: path to the input file on the DUT. 448 @param input_type: name of device type; 'touchpad' by default. 449 Types are returned by the _determine_input_type() 450 function. 451 input_type must be known. Check using has(). 452 453 """ 454 assert(input_type in self.devices) 455 node = self.devices[input_type].node 456 logging.info('Playing back finger-movement on %s, file=%s.', node, 457 filepath) 458 utils.run(self._PLAYBACK_COMMAND % (node, filepath)) 459 460 461 def blocking_playback(self, filepath, input_type='touchpad'): 462 """Playback a given set of inputs and sleep for duration. 463 464 The input file is of the format <name>\nE: <time> <input>\nE: ... 465 Find the total time by the difference between the first and last input. 466 467 @param filepath: path to the input file on the DUT. 468 @param input_type: name of device type; 'touchpad' by default. 469 Types are returned by the _determine_input_type() 470 function. 471 input_type must be known. Check using has(). 472 473 """ 474 with open(filepath) as fh: 475 lines = fh.readlines() 476 start = float(lines[0].split(' ')[1]) 477 end = float(lines[-1].split(' ')[1]) 478 sleep_time = end - start + self._PLAYBACK_OVERHEAD_LATENCY 479 start_time = time.time() 480 self.playback(filepath, input_type) 481 end_time = time.time() 482 elapsed_time = end_time - start_time 483 if elapsed_time < sleep_time: 484 sleep_time -= elapsed_time 485 logging.info('Blocking for %s seconds after playback.', sleep_time) 486 time.sleep(sleep_time) 487 488 489 def blocking_playback_of_default_file(self, filename, input_type='mouse'): 490 """Playback a default file and sleep for duration. 491 492 Use a default gesture file for the default keyboard/mouse, saved in 493 this folder. 494 Device should be emulated first. 495 496 @param filename: the name of the file (path is to this folder). 497 @param input_type: name of device type; 'mouse' by default. 498 Types are returned by the _determine_input_type() 499 function. 500 input_type must be known. Check using has(). 501 502 """ 503 current_dir = os.path.dirname(os.path.realpath(__file__)) 504 gesture_file = os.path.join(current_dir, filename) 505 self.blocking_playback(gesture_file, input_type=input_type) 506 507 508 def close(self): 509 """Kill emulation if necessary.""" 510 if self._emulated_device: 511 num_events_before = len(self._get_input_events()) 512 device_name = self._emulated_device.name 513 514 self._emulated_device.emulation_process.kill() 515 516 # Ensure there is one fewer input event before returning. 517 try: 518 expected = num_events_before - 1 519 utils.poll_for_condition( 520 lambda: len(self._get_input_events()) == expected, 521 exception=error.TestError()) 522 except error.TestError as e: 523 logging.warning('Could not kill emulated %s!', device_name) 524 525 self._emulated_device = None 526 527 528 def __enter__(self): 529 """Allow usage in 'with' statements.""" 530 return self 531 532 533 def __exit__(self, exc_type, exc_val, exc_tb): 534 """Release resources on completion of a 'with' statement.""" 535 self.close() 536