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 __exit__(self):
529        self.close()
530