Home | History | Annotate | Download | only in input_playback
      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 
      5 import glob
      6 import logging
      7 import os
      8 import subprocess
      9 import tempfile
     10 import time
     11 
     12 from autotest_lib.client.bin import utils
     13 from autotest_lib.client.common_lib import error
     14 
     15 
     16 class 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 
     38 class 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 __exit__(self):
    529         self.close()
    530