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 a keyboard as anything with any keys #2 to #248 inclusive,
56    # as defined in the linux input header.  This definition includes things
57    # like the power button, so reserve the "keyboard" label for things with
58    # letters/numbers and define the rest as "other_keyboard".
59    _MINIMAL_KEYBOARD_KEYS = ['1', 'Q', 'SPACE']
60    _KEYBOARD_KEYS = [
61            '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', 'MINUS', 'EQUAL',
62            'BACKSPACE', 'TAB', 'Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O',
63            'P', 'LEFTBRACE', 'RIGHTBRACE', 'ENTER', 'LEFTCTRL', 'A', 'S', 'D',
64            'F', 'G', 'H', 'J', 'K', 'L', 'SEMICOLON', 'APOSTROPHE', 'GRAVE',
65            'LEFTSHIFT', 'BACKSLASH', 'Z', 'X', 'C', 'V', 'B', 'N', 'M',
66            'COMMA', 'DOT', 'SLASH', 'RIGHTSHIFT', 'KPASTERISK', 'LEFTALT',
67            'SPACE', 'CAPSLOCK', 'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8',
68            'F9', 'F10', 'NUMLOCK', 'SCROLLLOCK', 'KP7', 'KP8', 'KP9',
69            'KPMINUS', 'KP4', 'KP5', 'KP6', 'KPPLUS', 'KP1', 'KP2', 'KP3',
70            'KP0', 'KPDOT', 'ZENKAKUHANKAKU', '102ND', 'F11', 'F12', 'RO',
71            'KATAKANA', 'HIRAGANA', 'HENKAN', 'KATAKANAHIRAGANA', 'MUHENKAN',
72            'KPJPCOMMA', 'KPENTER', 'RIGHTCTRL', 'KPSLASH', 'SYSRQ', 'RIGHTALT',
73            'LINEFEED', 'HOME', 'UP', 'PAGEUP', 'LEFT', 'RIGHT', 'END', 'DOWN',
74            'PAGEDOWN', 'INSERT', 'DELETE', 'MACRO', 'MUTE', 'VOLUMEDOWN',
75            'VOLUMEUP', 'POWER', 'KPEQUAL', 'KPPLUSMINUS', 'PAUSE', 'SCALE',
76            'KPCOMMA', 'HANGEUL', 'HANGUEL', 'HANJA', 'YEN', 'LEFTMETA',
77            'RIGHTMETA', 'COMPOSE', 'STOP', 'AGAIN', 'PROPS', 'UNDO', 'FRONT',
78            'COPY', 'OPEN', 'PASTE', 'FIND', 'CUT', 'HELP', 'MENU', 'CALC',
79            'SETUP', 'WAKEUP', 'FILE', 'SENDFILE', 'DELETEFILE', 'XFER',
80            'PROG1', 'PROG2', 'WWW', 'MSDOS', 'COFFEE', 'SCREENLOCK',
81            'DIRECTION', 'CYCLEWINDOWS', 'MAIL', 'BOOKMARKS', 'COMPUTER',
82            'BACK', 'FORWARD', 'CLOSECD', 'EJECTCD', 'EJECTCLOSECD', 'NEXTSONG',
83            'PLAYPAUSE', 'PREVIOUSSONG', 'STOPCD', 'RECORD', 'REWIND', 'PHONE',
84            'ISO', 'CONFIG', 'HOMEPAGE', 'REFRESH', 'EXIT', 'MOVE', 'EDIT',
85            'SCROLLUP', 'SCROLLDOWN', 'KPLEFTPAREN', 'KPRIGHTPAREN', 'NEW',
86            'REDO', 'F13', 'F14', 'F15', 'F16', 'F17', 'F18', 'F19', 'F20',
87            'F21', 'F22', 'F23', 'F24', 'PLAYCD', 'PAUSECD', 'PROG3', 'PROG4',
88            'DASHBOARD', 'SUSPEND', 'CLOSE', 'PLAY', 'FASTFORWARD', 'BASSBOOST',
89            'PRINT', 'HP', 'CAMERA', 'SOUND', 'QUESTION', 'EMAIL', 'CHAT',
90            'SEARCH', 'CONNECT', 'FINANCE', 'SPORT', 'SHOP', 'ALTERASE',
91            'CANCEL', 'BRIGHTNESSDOWN', 'BRIGHTNESSUP', 'MEDIA',
92            'SWITCHVIDEOMODE', 'KBDILLUMTOGGLE', 'KBDILLUMDOWN', 'KBDILLUMUP',
93            'SEND', 'REPLY', 'FORWARDMAIL', 'SAVE', 'DOCUMENTS', 'BATTERY',
94            'BLUETOOTH', 'WLAN', 'UWB', 'UNKNOWN', 'VIDEO_NEXT', 'VIDEO_PREV',
95            'BRIGHTNESS_CYCLE', 'BRIGHTNESS_AUTO', 'BRIGHTNESS_ZERO',
96            'DISPLAY_OFF', 'WWAN', 'WIMAX', 'RFKILL', 'MICMUTE']
97
98
99    def __init__(self):
100        self.devices = {}
101        self._emulated_device = None
102
103
104    def has(self, input_type):
105        """Return True/False if device has a input of given type.
106
107        @param input_type: string of type, e.g. 'touchpad'
108
109        """
110        return input_type in self.devices
111
112
113    def _get_input_events(self):
114        """Return a list of all input event nodes."""
115        return glob.glob('/dev/input/event*')
116
117
118    def emulate(self, input_type='mouse', property_file=None):
119        """
120        Emulate the given input (or default for type) with evemu-device.
121
122        Emulating more than one of the same device type will only allow playback
123        on the last one emulated.  The name of the last-emulated device is
124        noted to be sure this is the case.
125
126        Property files are made with the evemu-describe command,
127        e.g. 'evemu-describe /dev/input/event12 > property_file'.
128
129        @param input_type: 'mouse' or 'keyboard' to use default property files.
130                           Need not be specified if supplying own file.
131        @param property_file: Property file of device to be emulated.  Generate
132                              with 'evemu-describe' command on test image.
133
134        """
135        new_device = Device(input_type)
136        new_device.emulated = True
137
138        # Checks for any previous emulated device and kills the process
139        self.close()
140
141        if not property_file:
142            if input_type not in self._DEFAULT_PROPERTY_FILES:
143                raise error.TestError('Please supply a property file for input '
144                                      'type %s' % input_type)
145            current_dir = os.path.dirname(os.path.realpath(__file__))
146            property_file = os.path.join(
147                    current_dir, self._DEFAULT_PROPERTY_FILES[input_type])
148        if not os.path.isfile(property_file):
149            raise error.TestError('Property file %s not found!' % property_file)
150
151        logging.info('Emulating %s %s', input_type, property_file)
152        num_events_before = len(self._get_input_events())
153        new_device.emulation_process = subprocess.Popen(
154                ['evemu-device', property_file], stdout=subprocess.PIPE)
155        utils.poll_for_condition(
156                lambda: len(self._get_input_events()) > num_events_before,
157                exception=error.TestError('Error emulating %s!' % input_type))
158
159        with open(property_file) as fh:
160            name_line = fh.readline()  # Format "N: NAMEOFDEVICE"
161            new_device.name = name_line[3:-1]
162
163        self._emulated_device = new_device
164
165
166    def _find_device_properties(self, device):
167        """Return string of properties for given node.
168
169        @return: string of properties.
170
171        """
172        with tempfile.NamedTemporaryFile() as temp_file:
173            filename = temp_file.name
174            evtest_process = subprocess.Popen(['evtest', device],
175                                              stdout=temp_file)
176
177            def find_exit():
178                """Polling function for end of output."""
179                interrupt_cmd = 'grep "interrupt to exit" %s | wc -l' % filename
180                line_count = utils.run(interrupt_cmd).stdout.strip()
181                return line_count != '0'
182
183            utils.poll_for_condition(find_exit)
184            evtest_process.kill()
185            temp_file.seek(0)
186            props = temp_file.read()
187        return props
188
189
190    def _determine_input_type(self, props):
191        """Find input type (if any) from a string of properties.
192
193        @return: string of type, or None
194
195        """
196        if props.find('REL_X') >= 0 and props.find('REL_Y') >= 0:
197            if (props.find('ABS_MT_POSITION_X') >= 0 and
198                props.find('ABS_MT_POSITION_Y') >= 0):
199                return 'multitouch_mouse'
200            else:
201                return 'mouse'
202        if props.find('ABS_X') >= 0 and props.find('ABS_Y') >= 0:
203            if (props.find('BTN_STYLUS') >= 0 or
204                props.find('BTN_STYLUS2') >= 0 or
205                props.find('BTN_TOOL_PEN') >= 0):
206                return 'stylus'
207            if (props.find('ABS_PRESSURE') >= 0 or
208                props.find('BTN_TOUCH') >= 0):
209                if (props.find('BTN_LEFT') >= 0 or
210                    props.find('BTN_MIDDLE') >= 0 or
211                    props.find('BTN_RIGHT') >= 0 or
212                    props.find('BTN_TOOL_FINGER') >= 0):
213                    return 'touchpad'
214                else:
215                    return 'touchscreen'
216            if props.find('BTN_LEFT') >= 0:
217                return 'touchscreen'
218        if props.find('KEY_') >= 0:
219            for key in self._MINIMAL_KEYBOARD_KEYS:
220                if props.find('KEY_%s' % key) >= 0:
221                    return 'keyboard'
222            for key in self._KEYBOARD_KEYS:
223                if props.find('KEY_%s' % key) >= 0:
224                    return 'other_keyboard'
225        return
226
227
228    def _get_contents_of_file(self, filepath):
229        """Return the contents of the given file.
230
231        @param filepath: string of path to file
232
233        @returns: contents of file.  Assumes file exists.
234
235        """
236        return utils.run('cat %s' % filepath).stdout.strip()
237
238
239    def _find_input_name(self, device_dir):
240        """Find the associated input* name for the given device directory.
241
242        E.g. given '/dev/input/event4', return 'input3'.
243
244        @param device_dir: the device directory.
245
246        @returns: string of the associated input name.
247
248        """
249        input_names = glob.glob(os.path.join(device_dir, 'input', 'input*'))
250        if len(input_names) != 1:
251            logging.error('Input names found: %s', input_names)
252            raise error.TestError('Could not match input* to this device!')
253        return os.path.basename(input_names[0])
254
255
256    def _find_device_ids_for_styluses(self, device_dir):
257        """Find the fw_id and hw_id for the stylus in the given directory.
258
259        @param device_dir: the device directory.
260
261        @returns: firmware id, hardware id for this device.
262
263        """
264        hw_id = 'wacom' # Wacom styluses don't actually have hwids.
265        fw_id = None
266
267        # Find fw_id for wacom styluses via wacom_flash command.  Arguments
268        # to this command are wacom_flash (dummy placeholder arg) -a (i2c name)
269        # Find i2c name if any /dev/i2c-* link to this device's input event.
270        input_name = self._find_input_name(device_dir)
271        i2c_paths = glob.glob('/dev/i2c-*')
272        for i2c_path in i2c_paths:
273            class_folder = i2c_path.replace('dev', 'sys/class/i2c-adapter')
274            input_folder_path = os.path.join(class_folder, '*', '*',
275                                             'input', input_name)
276            contents_of_input_folder = glob.glob(input_folder_path)
277            if len(contents_of_input_folder) != 0:
278                i2c_name = i2c_path[len('/dev/'):]
279                cmd = 'wacom_flash dummy -a %s' % i2c_name
280                fw_id = utils.run(cmd).stdout.split()[-1]
281                break
282
283        if fw_id == '':
284            fw_id = None
285        return fw_id, hw_id
286
287
288    def _find_device_ids(self, device_dir, input_type, name):
289        """Find the fw_id and hw_id for the given device directory.
290
291        Finding fw_id and hw_id applicable only for touchpads, touchscreens,
292        and styluses.
293
294        @param device_dir: the device directory.
295        @param input_type: string of input type.
296        @param name: string of input name.
297
298        @returns: firmware id, hardware id
299
300        """
301        fw_id, hw_id = None, None
302
303        if not device_dir or input_type not in ['touchpad', 'touchscreen',
304                                                'stylus']:
305            return fw_id, hw_id
306        if input_type == 'stylus':
307            return self._find_device_ids_for_styluses(device_dir)
308
309        # Touch devices with custom drivers usually save this info as a file.
310        fw_filenames = ['fw_version', 'firmware_version', 'firmware_id']
311        for fw_filename in fw_filenames:
312            fw_path = os.path.join(device_dir, fw_filename)
313            if os.path.exists(fw_path):
314                if fw_id:
315                    logging.warning('Found new potential fw_id when previous '
316                                    'value was %s!', fw_id)
317                fw_id = self._get_contents_of_file(fw_path)
318
319        hw_filenames = ['hw_version', 'product_id', 'board_id']
320        for hw_filename in hw_filenames:
321            hw_path = os.path.join(device_dir, hw_filename)
322            if os.path.exists(hw_path):
323                if hw_id:
324                    logging.warning('Found new potential hw_id when previous '
325                                    'value was %s!', hw_id)
326                hw_id = self._get_contents_of_file(hw_path)
327
328        # Hw_ids for Weida and 2nd gen Synaptics are different.
329        if not hw_id:
330            id_folder = os.path.abspath(os.path.join(device_dir, '..', 'id'))
331            product_path = os.path.join(id_folder, 'product')
332            vendor_path = os.path.join(id_folder, 'vendor')
333
334            if os.path.isfile(product_path):
335                product = self._get_contents_of_file(product_path)
336                if name.startswith('WD'): # Weida ts, e.g. sumo
337                    if os.path.isfile(vendor_path):
338                        vendor = self._get_contents_of_file(vendor_path)
339                        hw_id = vendor + product
340                else: # Synaptics tp or ts, e.g. heli, lulu, setzer
341                    hw_id = product
342
343        if not fw_id:
344            # Fw_ids for 2nd gen Synaptics can only be found via rmi4update.
345            # See if any /dev/hidraw* link to this device's input event.
346            input_name = self._find_input_name(device_dir)
347            hidraws = glob.glob('/dev/hidraw*')
348            for hidraw in hidraws:
349                class_folder = hidraw.replace('dev', 'sys/class/hidraw')
350                input_folder_path = os.path.join(class_folder, 'device',
351                                                 'input', input_name)
352                if os.path.exists(input_folder_path):
353                    fw_id = utils.run('rmi4update -p -d %s' % hidraw,
354                                      ignore_status=True).stdout.strip()
355                    if fw_id == '':
356                        fw_id = None
357                    break
358
359        return fw_id, hw_id
360
361
362    def find_connected_inputs(self):
363        """Determine the nodes of all present input devices, if any.
364
365        Cycle through all possible /dev/input/event* and find which ones
366        are touchpads, touchscreens, mice, keyboards, etc.
367        These nodes can be used for playback later.
368        If the type of input is already emulated, prefer that device. Otherwise,
369        prefer the last node found of that type (e.g. for multiple touchpads).
370        Record the found devices in self.devices.
371
372        """
373        self.devices = {}  # Discard any previously seen nodes.
374
375        input_events = self._get_input_events()
376        for event in input_events:
377            properties = self._find_device_properties(event)
378            input_type = self._determine_input_type(properties)
379            if input_type:
380                new_device = Device(input_type)
381                new_device.node = event
382
383                class_folder = event.replace('dev', 'sys/class')
384                name_file = os.path.join(class_folder, 'device', 'name')
385                if os.path.isfile(name_file):
386                    name = self._get_contents_of_file(name_file)
387                logging.info('Found %s: %s at %s.', input_type, name, event)
388
389                # If a particular device is expected, make sure name matches.
390                if (self._emulated_device and
391                    self._emulated_device.input_type == input_type):
392                    if self._emulated_device.name != name:
393                        continue
394                    else:
395                        new_device.emulated = True
396                        process = self._emulated_device.emulation_process
397                        new_device.emulation_process = process
398                new_device.name = name
399
400                # Find the devices folder containing power info
401                # e.g. /sys/class/event4/device/device
402                # Search that folder for hwid and fwid
403                device_dir = os.path.join(class_folder, 'device', 'device')
404                if os.path.exists(device_dir):
405                    new_device.device_dir = device_dir
406                    new_device.fw_id, new_device.hw_id = self._find_device_ids(
407                            device_dir, input_type, new_device.name)
408
409                if new_device.emulated:
410                    self._emulated_device = new_device
411
412                self.devices[input_type] = new_device
413                logging.debug(self.devices[input_type])
414
415
416    def playback(self, filepath, input_type='touchpad'):
417        """Playback a given input file.
418
419        Create input file using evemu-record.
420        E.g. 'evemu-record $NODE -1 > $FILENAME'
421
422        @param filepath: path to the input file on the DUT.
423        @param input_type: name of device type; 'touchpad' by default.
424                           Types are returned by the _determine_input_type()
425                           function.
426                           input_type must be known. Check using has().
427
428        """
429        assert(input_type in self.devices)
430        node = self.devices[input_type].node
431        logging.info('Playing back finger-movement on %s, file=%s.', node,
432                     filepath)
433        utils.run(self._PLAYBACK_COMMAND % (node, filepath))
434
435
436    def blocking_playback(self, filepath, input_type='touchpad'):
437        """Playback a given set of inputs and sleep for duration.
438
439        The input file is of the format <name>\nE: <time> <input>\nE: ...
440        Find the total time by the difference between the first and last input.
441
442        @param filepath: path to the input file on the DUT.
443        @param input_type: name of device type; 'touchpad' by default.
444                           Types are returned by the _determine_input_type()
445                           function.
446                           input_type must be known. Check using has().
447
448        """
449        with open(filepath) as fh:
450            lines = fh.readlines()
451            start = float(lines[0].split(' ')[1])
452            end = float(lines[-1].split(' ')[1])
453            sleep_time = end - start
454        self.playback(filepath, input_type)
455        logging.info('Sleeping for %s seconds during playback.', sleep_time)
456        time.sleep(sleep_time)
457
458
459    def blocking_playback_of_default_file(self, filename, input_type='mouse'):
460        """Playback a default file and sleep for duration.
461
462        Use a default gesture file for the default keyboard/mouse, saved in
463        this folder.
464        Device should be emulated first.
465
466        @param filename: the name of the file (path is to this folder).
467        @param input_type: name of device type; 'mouse' by default.
468                           Types are returned by the _determine_input_type()
469                           function.
470                           input_type must be known. Check using has().
471
472        """
473        current_dir = os.path.dirname(os.path.realpath(__file__))
474        gesture_file = os.path.join(current_dir, filename)
475        self.blocking_playback(gesture_file, input_type=input_type)
476
477
478    def close(self):
479        """Kill emulation if necessary."""
480        if self._emulated_device:
481            self._emulated_device.emulation_process.kill()
482
483
484    def __exit__(self):
485        self.close()
486