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 _WACOM_VENDOR_ID = '2d1f' 104 105 def __init__(self): 106 self.devices = {} 107 self._emulated_device = None 108 109 110 def has(self, input_type): 111 """Return True/False if device has a input of given type. 112 113 @param input_type: string of type, e.g. 'touchpad' 114 115 """ 116 return input_type in self.devices 117 118 119 def _get_input_events(self): 120 """Return a list of all input event nodes.""" 121 return glob.glob('/dev/input/event*') 122 123 124 def emulate(self, input_type='mouse', property_file=None): 125 """ 126 Emulate the given input (or default for type) with evemu-device. 127 128 Emulating more than one of the same device type will only allow playback 129 on the last one emulated. The name of the last-emulated device is 130 noted to be sure this is the case. 131 132 Property files are made with the evemu-describe command, 133 e.g. 'evemu-describe /dev/input/event12 > property_file'. 134 135 @param input_type: 'mouse' or 'keyboard' to use default property files. 136 Need not be specified if supplying own file. 137 @param property_file: Property file of device to be emulated. Generate 138 with 'evemu-describe' command on test image. 139 140 """ 141 new_device = Device(input_type) 142 new_device.emulated = True 143 144 # Checks for any previous emulated device and kills the process 145 self.close() 146 147 if not property_file: 148 if input_type not in self._DEFAULT_PROPERTY_FILES: 149 raise error.TestError('Please supply a property file for input ' 150 'type %s' % input_type) 151 current_dir = os.path.dirname(os.path.realpath(__file__)) 152 property_file = os.path.join( 153 current_dir, self._DEFAULT_PROPERTY_FILES[input_type]) 154 if not os.path.isfile(property_file): 155 raise error.TestError('Property file %s not found!' % property_file) 156 157 with open(property_file) as fh: 158 name_line = fh.readline() # Format "N: NAMEOFDEVICE" 159 new_device.name = name_line[3:-1] 160 161 logging.info('Emulating %s %s (%s).', input_type, new_device.name, 162 property_file) 163 num_events_before = len(self._get_input_events()) 164 new_device.emulation_process = subprocess.Popen( 165 ['evemu-device', property_file], stdout=subprocess.PIPE) 166 167 self._emulated_device = new_device 168 169 # Ensure there are more input events than there were before. 170 try: 171 expected = num_events_before + 1 172 exception = error.TestError('Error emulating %s!' % input_type) 173 utils.poll_for_condition( 174 lambda: len(self._get_input_events()) == expected, 175 exception=exception) 176 except error.TestError as e: 177 self.close() 178 raise e 179 180 181 def _find_device_properties(self, device): 182 """Return string of properties for given node. 183 184 @return: string of properties. 185 186 """ 187 with tempfile.NamedTemporaryFile(mode='w+') as temp_file: 188 filename = temp_file.name 189 evtest_process = subprocess.Popen(['evtest', device], 190 stdout=temp_file) 191 192 def find_exit(): 193 """Polling function for end of output.""" 194 interrupt_cmd = 'grep "interrupt to exit" %s | wc -l' % filename 195 line_count = utils.run(interrupt_cmd).stdout.strip() 196 return line_count != '0' 197 198 utils.poll_for_condition(find_exit) 199 evtest_process.kill() 200 temp_file.seek(0) 201 props = temp_file.read() 202 return props 203 204 205 def _determine_input_type(self, props): 206 """Find input type (if any) from a string of properties. 207 208 @return: string of type, or None 209 210 """ 211 if props.find('REL_X') >= 0 and props.find('REL_Y') >= 0: 212 if (props.find('ABS_MT_POSITION_X') >= 0 and 213 props.find('ABS_MT_POSITION_Y') >= 0): 214 return 'multitouch_mouse' 215 else: 216 return 'mouse' 217 if props.find('ABS_X') >= 0 and props.find('ABS_Y') >= 0: 218 if (props.find('BTN_STYLUS') >= 0 or 219 props.find('BTN_STYLUS2') >= 0 or 220 props.find('BTN_TOOL_PEN') >= 0): 221 return 'stylus' 222 if (props.find('ABS_PRESSURE') >= 0 or 223 props.find('BTN_TOUCH') >= 0): 224 if (props.find('BTN_LEFT') >= 0 or 225 props.find('BTN_MIDDLE') >= 0 or 226 props.find('BTN_RIGHT') >= 0 or 227 props.find('BTN_TOOL_FINGER') >= 0): 228 return 'touchpad' 229 else: 230 return 'touchscreen' 231 if props.find('BTN_LEFT') >= 0: 232 return 'touchscreen' 233 if props.find('KEY_') >= 0: 234 for key in self._MINIMAL_KEYBOARD_KEYS: 235 if props.find('KEY_%s' % key) >= 0: 236 return 'keyboard' 237 for key in self._KEYBOARD_KEYS: 238 if props.find('KEY_%s' % key) >= 0: 239 return 'other_keyboard' 240 return 241 242 243 def _get_contents_of_file(self, filepath): 244 """Return the contents of the given file. 245 246 @param filepath: string of path to file 247 248 @returns: contents of file. Assumes file exists. 249 250 """ 251 return utils.run('cat %s' % filepath).stdout.strip() 252 253 254 def _get_vendor_id(self, node_dir): 255 """Gets the vendor ID of an input device, given its node directory. 256 257 @param node_dir: the directory for the input node in sysfs (e.g. 258 /sys/class/input/event1) 259 260 @returns: the vendor ID, as a string of four lower-case hex digits. 261 """ 262 vendor_id_path = os.path.join(node_dir, 'device/id/vendor') 263 if not os.path.exists(vendor_id_path): 264 raise error.TestError('Could not read vendor ID for ' + node_dir) 265 return self._get_contents_of_file(vendor_id_path).lower() 266 267 268 def _find_input_name(self, device_dir, name=None): 269 """Find the associated input* name for the given device directory. 270 271 E.g. given '/dev/input/event4', return 'input3'. 272 273 @param device_dir: the device directory. 274 @param name: the device name. 275 276 277 @returns: string of the associated input name. 278 279 """ 280 input_names = glob.glob(os.path.join(device_dir, 'input', 'input*')) 281 for input_name in input_names: 282 name_path = os.path.join(input_name, 'name') 283 if not os.path.exists(name_path): 284 continue 285 if name == self._get_contents_of_file(name_path): 286 return os.path.basename(input_name) 287 # Raise if name could not be matched. 288 logging.error('Input names found(%s): %s', device_dir, input_names) 289 raise error.TestError('Could not match input* to this device!') 290 291 292 def _find_device_ids_for_styluses(self, node_dir, device_dir, name=None): 293 """Find the fw_id and hw_id for the stylus in the given directory. 294 295 @param node_dir: the directory for the input node in sysfs (e.g. 296 /sys/class/input/event1) 297 @param device_dir: the device directory. 298 @param name: the device name. 299 300 @returns: firmware ID, hardware ID for this device. Since styluses don't 301 really have hardware IDs, this will actually be 'usi' or 302 'wacom' depending on the stylus type. Firmware ID may be None. 303 304 """ 305 if self._get_vendor_id(node_dir) != self._WACOM_VENDOR_ID: 306 # The stylus device only has a distinct hardware and firmware ID if 307 # it's a Wacom digitizer. Otherwise, a USI stylus is being used, in 308 # which case it's handled by the touchscreen controller. So, there's 309 # no point in looking for a firmware ID unless the stylus has a 310 # Wacom vendor ID. 311 return None, 'usi' 312 313 hw_id = 'wacom' # Wacom styluses don't actually have hwids. 314 fw_id = None 315 316 # Find fw_id for wacom styluses via wacom_flash command. Arguments 317 # to this command are wacom_flash (dummy placeholder arg) -a (i2c name) 318 # Find i2c name if any /dev/i2c-* link to this device's input event. 319 input_name = self._find_input_name(device_dir, name) 320 i2c_paths = glob.glob('/dev/i2c-*') 321 for i2c_path in i2c_paths: 322 class_folder = i2c_path.replace('dev', 'sys/class/i2c-adapter') 323 input_folder_path = os.path.join(class_folder, '*', '*', 324 'input', input_name) 325 contents_of_input_folder = glob.glob(input_folder_path) 326 if len(contents_of_input_folder) != 0: 327 i2c_name = i2c_path[len('/dev/'):] 328 cmd = 'wacom_flash dummy -a %s' % i2c_name 329 # Do not throw an exception if wacom_flash does not exist. 330 result = utils.run(cmd, ignore_status=True) 331 if result.exit_status == 0: 332 fw_id = result.stdout.split()[-1] 333 break 334 335 if fw_id == '': 336 fw_id = None 337 return fw_id, hw_id 338 339 340 def _find_device_ids(self, node_dir, device_dir, input_type, name): 341 """Find the fw_id and hw_id for the given device directory. 342 343 Finding fw_id and hw_id applicable only for touchpads, touchscreens, 344 and styluses. 345 346 @param node_dir: the directory for the input node in sysfs (e.g. 347 /sys/class/input/event1) 348 @param device_dir: the device directory. 349 @param input_type: string of input type. 350 @param name: string of input name. 351 352 @returns: firmware id, hardware id 353 354 """ 355 fw_id, hw_id = None, None 356 357 if not device_dir or input_type not in ['touchpad', 'touchscreen', 358 'stylus']: 359 return fw_id, hw_id 360 if input_type == 'stylus': 361 return self._find_device_ids_for_styluses(node_dir, device_dir, 362 name) 363 364 # Touch devices with custom drivers usually save this info as a file. 365 fw_filenames = ['fw_version', 'firmware_version', 'firmware_id'] 366 for fw_filename in fw_filenames: 367 fw_path = os.path.join(device_dir, fw_filename) 368 if os.path.exists(fw_path): 369 if fw_id: 370 logging.warning('Found new potential fw_id when previous ' 371 'value was %s!', fw_id) 372 fw_id = self._get_contents_of_file(fw_path) 373 374 hw_filenames = ['hw_version', 'product_id', 'board_id'] 375 for hw_filename in hw_filenames: 376 hw_path = os.path.join(device_dir, hw_filename) 377 if os.path.exists(hw_path): 378 if hw_id: 379 logging.warning('Found new potential hw_id when previous ' 380 'value was %s!', hw_id) 381 hw_id = self._get_contents_of_file(hw_path) 382 383 # Hw_ids for Weida and 2nd gen Synaptics are different. 384 if not hw_id: 385 id_folder = os.path.abspath(os.path.join(device_dir, '..', 'id')) 386 product_path = os.path.join(id_folder, 'product') 387 vendor_path = os.path.join(id_folder, 'vendor') 388 389 if os.path.isfile(product_path): 390 product = self._get_contents_of_file(product_path) 391 if name.startswith('WD'): # Weida ts, e.g. sumo 392 if os.path.isfile(vendor_path): 393 vendor = self._get_contents_of_file(vendor_path) 394 hw_id = vendor + product 395 else: # Synaptics tp or ts, e.g. heli, lulu, setzer 396 hw_id = product 397 398 if not fw_id: 399 # Fw_ids for 2nd gen Synaptics can only be found via rmi4update. 400 # See if any /dev/hidraw* link to this device's input event. 401 input_name = self._find_input_name(device_dir, name) 402 hidraws = glob.glob('/dev/hidraw*') 403 for hidraw in hidraws: 404 class_folder = hidraw.replace('dev', 'sys/class/hidraw') 405 input_folder_path = os.path.join(class_folder, 'device', 406 'input', input_name) 407 if os.path.exists(input_folder_path): 408 fw_id = utils.run('rmi4update -p -d %s' % hidraw, 409 ignore_status=True).stdout.strip() 410 if fw_id == '': 411 fw_id = None 412 break 413 414 return fw_id, hw_id 415 416 417 def find_connected_inputs(self): 418 """Determine the nodes of all present input devices, if any. 419 420 Cycle through all possible /dev/input/event* and find which ones 421 are touchpads, touchscreens, mice, keyboards, etc. 422 These nodes can be used for playback later. 423 If the type of input is already emulated, prefer that device. Otherwise, 424 prefer the last node found of that type (e.g. for multiple touchpads). 425 Record the found devices in self.devices. 426 427 """ 428 self.devices = {} # Discard any previously seen nodes. 429 430 input_events = self._get_input_events() 431 for event in input_events: 432 properties = self._find_device_properties(event) 433 input_type = self._determine_input_type(properties) 434 if input_type: 435 new_device = Device(input_type) 436 new_device.node = event 437 438 class_folder = event.replace('dev', 'sys/class') 439 name_file = os.path.join(class_folder, 'device', 'name') 440 if os.path.isfile(name_file): 441 name = self._get_contents_of_file(name_file) 442 logging.info('Found %s: %s at %s.', input_type, name, event) 443 444 # If a particular device is expected, make sure name matches. 445 if (self._emulated_device and 446 self._emulated_device.input_type == input_type): 447 if self._emulated_device.name != name: 448 continue 449 else: 450 new_device.emulated = True 451 process = self._emulated_device.emulation_process 452 new_device.emulation_process = process 453 new_device.name = name 454 455 # Find the devices folder containing power info 456 # e.g. /sys/class/event4/device/device 457 # Search that folder for hwid and fwid 458 device_dir = os.path.join(class_folder, 'device', 'device') 459 if os.path.exists(device_dir): 460 new_device.device_dir = device_dir 461 new_device.fw_id, new_device.hw_id = self._find_device_ids( 462 class_folder, device_dir, input_type, 463 new_device.name) 464 465 if new_device.emulated: 466 self._emulated_device = new_device 467 468 self.devices[input_type] = new_device 469 logging.debug(self.devices[input_type]) 470 471 472 def playback(self, filepath, input_type='touchpad'): 473 """Playback a given input file. 474 475 Create input file using evemu-record. 476 E.g. 'evemu-record $NODE -1 > $FILENAME' 477 478 @param filepath: path to the input file on the DUT. 479 @param input_type: name of device type; 'touchpad' by default. 480 Types are returned by the _determine_input_type() 481 function. 482 input_type must be known. Check using has(). 483 484 """ 485 assert(input_type in self.devices) 486 node = self.devices[input_type].node 487 logging.info('Playing back finger-movement on %s, file=%s.', node, 488 filepath) 489 utils.run(self._PLAYBACK_COMMAND % (node, filepath)) 490 491 492 def blocking_playback(self, filepath, input_type='touchpad'): 493 """Playback a given set of inputs and sleep for duration. 494 495 The input file is of the format <name>\nE: <time> <input>\nE: ... 496 Find the total time by the difference between the first and last input. 497 498 @param filepath: path to the input file on the DUT. 499 @param input_type: name of device type; 'touchpad' by default. 500 Types are returned by the _determine_input_type() 501 function. 502 input_type must be known. Check using has(). 503 504 """ 505 with open(filepath) as fh: 506 lines = fh.readlines() 507 start = float(lines[0].split(' ')[1]) 508 end = float(lines[-1].split(' ')[1]) 509 sleep_time = end - start + self._PLAYBACK_OVERHEAD_LATENCY 510 start_time = time.time() 511 self.playback(filepath, input_type) 512 end_time = time.time() 513 elapsed_time = end_time - start_time 514 if elapsed_time < sleep_time: 515 sleep_time -= elapsed_time 516 logging.info('Blocking for %s seconds after playback.', sleep_time) 517 time.sleep(sleep_time) 518 519 520 def blocking_playback_of_default_file(self, filename, input_type='mouse'): 521 """Playback a default file and sleep for duration. 522 523 Use a default gesture file for the default keyboard/mouse, saved in 524 this folder. 525 Device should be emulated first. 526 527 @param filename: the name of the file (path is to this folder). 528 @param input_type: name of device type; 'mouse' by default. 529 Types are returned by the _determine_input_type() 530 function. 531 input_type must be known. Check using has(). 532 533 """ 534 current_dir = os.path.dirname(os.path.realpath(__file__)) 535 gesture_file = os.path.join(current_dir, filename) 536 self.blocking_playback(gesture_file, input_type=input_type) 537 538 539 def close(self): 540 """Kill emulation if necessary.""" 541 if self._emulated_device: 542 num_events_before = len(self._get_input_events()) 543 device_name = self._emulated_device.name 544 545 self._emulated_device.emulation_process.kill() 546 547 # Ensure there is one fewer input event before returning. 548 try: 549 expected = num_events_before - 1 550 utils.poll_for_condition( 551 lambda: len(self._get_input_events()) == expected, 552 exception=error.TestError()) 553 except error.TestError as e: 554 logging.warning('Could not kill emulated %s!', device_name) 555 556 self._emulated_device = None 557 558 559 def __enter__(self): 560 """Allow usage in 'with' statements.""" 561 return self 562 563 564 def __exit__(self, exc_type, exc_val, exc_tb): 565 """Release resources on completion of a 'with' statement.""" 566 self.close() 567