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