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): 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 260 @returns: string of the associated input name. 261 262 """ 263 input_names = glob.glob(os.path.join(device_dir, 'input', 'input*')) 264 if len(input_names) != 1: 265 logging.error('Input names found: %s', input_names) 266 raise error.TestError('Could not match input* to this device!') 267 return os.path.basename(input_names[0]) 268 269 270 def _find_device_ids_for_styluses(self, device_dir): 271 """Find the fw_id and hw_id for the stylus in the given directory. 272 273 @param device_dir: the device directory. 274 275 @returns: firmware id, hardware id for this device. 276 277 """ 278 hw_id = 'wacom' # Wacom styluses don't actually have hwids. 279 fw_id = None 280 281 # Find fw_id for wacom styluses via wacom_flash command. Arguments 282 # to this command are wacom_flash (dummy placeholder arg) -a (i2c name) 283 # Find i2c name if any /dev/i2c-* link to this device's input event. 284 input_name = self._find_input_name(device_dir) 285 i2c_paths = glob.glob('/dev/i2c-*') 286 for i2c_path in i2c_paths: 287 class_folder = i2c_path.replace('dev', 'sys/class/i2c-adapter') 288 input_folder_path = os.path.join(class_folder, '*', '*', 289 'input', input_name) 290 contents_of_input_folder = glob.glob(input_folder_path) 291 if len(contents_of_input_folder) != 0: 292 i2c_name = i2c_path[len('/dev/'):] 293 cmd = 'wacom_flash dummy -a %s' % i2c_name 294 # Do not throw an exception if wacom_flash does not exist. 295 result = utils.run(cmd, ignore_status=True) 296 if result.exit_status == 0: 297 fw_id = result.stdout.split()[-1] 298 break 299 300 if fw_id == '': 301 fw_id = None 302 return fw_id, hw_id 303 304 305 def _find_device_ids(self, device_dir, input_type, name): 306 """Find the fw_id and hw_id for the given device directory. 307 308 Finding fw_id and hw_id applicable only for touchpads, touchscreens, 309 and styluses. 310 311 @param device_dir: the device directory. 312 @param input_type: string of input type. 313 @param name: string of input name. 314 315 @returns: firmware id, hardware id 316 317 """ 318 fw_id, hw_id = None, None 319 320 if not device_dir or input_type not in ['touchpad', 'touchscreen', 321 'stylus']: 322 return fw_id, hw_id 323 if input_type == 'stylus': 324 return self._find_device_ids_for_styluses(device_dir) 325 326 # Touch devices with custom drivers usually save this info as a file. 327 fw_filenames = ['fw_version', 'firmware_version', 'firmware_id'] 328 for fw_filename in fw_filenames: 329 fw_path = os.path.join(device_dir, fw_filename) 330 if os.path.exists(fw_path): 331 if fw_id: 332 logging.warning('Found new potential fw_id when previous ' 333 'value was %s!', fw_id) 334 fw_id = self._get_contents_of_file(fw_path) 335 336 hw_filenames = ['hw_version', 'product_id', 'board_id'] 337 for hw_filename in hw_filenames: 338 hw_path = os.path.join(device_dir, hw_filename) 339 if os.path.exists(hw_path): 340 if hw_id: 341 logging.warning('Found new potential hw_id when previous ' 342 'value was %s!', hw_id) 343 hw_id = self._get_contents_of_file(hw_path) 344 345 # Hw_ids for Weida and 2nd gen Synaptics are different. 346 if not hw_id: 347 id_folder = os.path.abspath(os.path.join(device_dir, '..', 'id')) 348 product_path = os.path.join(id_folder, 'product') 349 vendor_path = os.path.join(id_folder, 'vendor') 350 351 if os.path.isfile(product_path): 352 product = self._get_contents_of_file(product_path) 353 if name.startswith('WD'): # Weida ts, e.g. sumo 354 if os.path.isfile(vendor_path): 355 vendor = self._get_contents_of_file(vendor_path) 356 hw_id = vendor + product 357 else: # Synaptics tp or ts, e.g. heli, lulu, setzer 358 hw_id = product 359 360 if not fw_id: 361 # Fw_ids for 2nd gen Synaptics can only be found via rmi4update. 362 # See if any /dev/hidraw* link to this device's input event. 363 input_name = self._find_input_name(device_dir) 364 hidraws = glob.glob('/dev/hidraw*') 365 for hidraw in hidraws: 366 class_folder = hidraw.replace('dev', 'sys/class/hidraw') 367 input_folder_path = os.path.join(class_folder, 'device', 368 'input', input_name) 369 if os.path.exists(input_folder_path): 370 fw_id = utils.run('rmi4update -p -d %s' % hidraw, 371 ignore_status=True).stdout.strip() 372 if fw_id == '': 373 fw_id = None 374 break 375 376 return fw_id, hw_id 377 378 379 def find_connected_inputs(self): 380 """Determine the nodes of all present input devices, if any. 381 382 Cycle through all possible /dev/input/event* and find which ones 383 are touchpads, touchscreens, mice, keyboards, etc. 384 These nodes can be used for playback later. 385 If the type of input is already emulated, prefer that device. Otherwise, 386 prefer the last node found of that type (e.g. for multiple touchpads). 387 Record the found devices in self.devices. 388 389 """ 390 self.devices = {} # Discard any previously seen nodes. 391 392 input_events = self._get_input_events() 393 for event in input_events: 394 properties = self._find_device_properties(event) 395 input_type = self._determine_input_type(properties) 396 if input_type: 397 new_device = Device(input_type) 398 new_device.node = event 399 400 class_folder = event.replace('dev', 'sys/class') 401 name_file = os.path.join(class_folder, 'device', 'name') 402 if os.path.isfile(name_file): 403 name = self._get_contents_of_file(name_file) 404 logging.info('Found %s: %s at %s.', input_type, name, event) 405 406 # If a particular device is expected, make sure name matches. 407 if (self._emulated_device and 408 self._emulated_device.input_type == input_type): 409 if self._emulated_device.name != name: 410 continue 411 else: 412 new_device.emulated = True 413 process = self._emulated_device.emulation_process 414 new_device.emulation_process = process 415 new_device.name = name 416 417 # Find the devices folder containing power info 418 # e.g. /sys/class/event4/device/device 419 # Search that folder for hwid and fwid 420 device_dir = os.path.join(class_folder, 'device', 'device') 421 if os.path.exists(device_dir): 422 new_device.device_dir = device_dir 423 new_device.fw_id, new_device.hw_id = self._find_device_ids( 424 device_dir, input_type, new_device.name) 425 426 if new_device.emulated: 427 self._emulated_device = new_device 428 429 self.devices[input_type] = new_device 430 logging.debug(self.devices[input_type]) 431 432 433 def playback(self, filepath, input_type='touchpad'): 434 """Playback a given input file. 435 436 Create input file using evemu-record. 437 E.g. 'evemu-record $NODE -1 > $FILENAME' 438 439 @param filepath: path to the input file on the DUT. 440 @param input_type: name of device type; 'touchpad' by default. 441 Types are returned by the _determine_input_type() 442 function. 443 input_type must be known. Check using has(). 444 445 """ 446 assert(input_type in self.devices) 447 node = self.devices[input_type].node 448 logging.info('Playing back finger-movement on %s, file=%s.', node, 449 filepath) 450 utils.run(self._PLAYBACK_COMMAND % (node, filepath)) 451 452 453 def blocking_playback(self, filepath, input_type='touchpad'): 454 """Playback a given set of inputs and sleep for duration. 455 456 The input file is of the format <name>\nE: <time> <input>\nE: ... 457 Find the total time by the difference between the first and last input. 458 459 @param filepath: path to the input file on the DUT. 460 @param input_type: name of device type; 'touchpad' by default. 461 Types are returned by the _determine_input_type() 462 function. 463 input_type must be known. Check using has(). 464 465 """ 466 with open(filepath) as fh: 467 lines = fh.readlines() 468 start = float(lines[0].split(' ')[1]) 469 end = float(lines[-1].split(' ')[1]) 470 sleep_time = end - start + self._PLAYBACK_OVERHEAD_LATENCY 471 start_time = time.time() 472 self.playback(filepath, input_type) 473 end_time = time.time() 474 elapsed_time = end_time - start_time 475 if elapsed_time < sleep_time: 476 sleep_time -= elapsed_time 477 logging.info('Blocking for %s seconds after playback.', sleep_time) 478 time.sleep(sleep_time) 479 480 481 def blocking_playback_of_default_file(self, filename, input_type='mouse'): 482 """Playback a default file and sleep for duration. 483 484 Use a default gesture file for the default keyboard/mouse, saved in 485 this folder. 486 Device should be emulated first. 487 488 @param filename: the name of the file (path is to this folder). 489 @param input_type: name of device type; 'mouse' by default. 490 Types are returned by the _determine_input_type() 491 function. 492 input_type must be known. Check using has(). 493 494 """ 495 current_dir = os.path.dirname(os.path.realpath(__file__)) 496 gesture_file = os.path.join(current_dir, filename) 497 self.blocking_playback(gesture_file, input_type=input_type) 498 499 500 def close(self): 501 """Kill emulation if necessary.""" 502 if self._emulated_device: 503 num_events_before = len(self._get_input_events()) 504 device_name = self._emulated_device.name 505 506 self._emulated_device.emulation_process.kill() 507 508 # Ensure there is one fewer input event before returning. 509 try: 510 expected = num_events_before - 1 511 utils.poll_for_condition( 512 lambda: len(self._get_input_events()) == expected, 513 exception=error.TestError()) 514 except error.TestError as e: 515 logging.warning('Could not kill emulated %s!', device_name) 516 517 self._emulated_device = None 518 519 520 def __exit__(self): 521 self.close() 522