• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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