display_facade_native.py revision 10a1c6a6773d2f42ee27e396ecf318b7bac457b3
1# Copyright 2014 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"""Facade to access the display-related functionality."""
6
7import exceptions
8import multiprocessing
9import numpy
10import os
11import re
12import time
13from autotest_lib.client.bin import utils
14from autotest_lib.client.common_lib import error
15from autotest_lib.client.common_lib.cros import chrome, retry
16from autotest_lib.client.cros import constants, sys_power
17from autotest_lib.client.cros.graphics import graphics_utils
18from autotest_lib.client.cros.multimedia import image_generator
19
20class TimeoutException(Exception):
21    pass
22
23
24_FLAKY_CALL_RETRY_TIMEOUT_SEC = 60
25_FLAKY_CHROME_CALL_RETRY_DELAY_SEC = 1
26_FLAKY_DISPLAY_CALL_RETRY_DELAY_SEC = 2
27
28_retry_chrome_call = retry.retry(
29        (chrome.Error, exceptions.IndexError),
30        timeout_min=_FLAKY_CALL_RETRY_TIMEOUT_SEC / 60.0,
31        delay_sec=_FLAKY_CHROME_CALL_RETRY_DELAY_SEC)
32
33_retry_display_call = retry.retry(
34        (KeyError, error.CmdError),
35        timeout_min=_FLAKY_CALL_RETRY_TIMEOUT_SEC / 60.0,
36        delay_sec=_FLAKY_DISPLAY_CALL_RETRY_DELAY_SEC)
37
38
39class DisplayFacadeNative(object):
40    """Facade to access the display-related functionality.
41
42    The methods inside this class only accept Python native types.
43    """
44
45    CALIBRATION_IMAGE_PATH = '/tmp/calibration.svg'
46
47    def __init__(self, chrome):
48        self._chrome = chrome
49        self._browser = chrome.browser
50        self._image_generator = image_generator.ImageGenerator()
51
52
53    @_retry_chrome_call
54    def get_display_info(self):
55        """Gets the display info from Chrome.system.display API.
56
57        @return array of dict for display info.
58        """
59
60        extension = self._chrome.get_extension(
61                constants.DISPLAY_TEST_EXTENSION)
62        if not extension:
63            raise RuntimeError('Display test extension not found')
64        extension.ExecuteJavaScript('window.__display_info = null;')
65        extension.ExecuteJavaScript(
66                "chrome.system.display.getInfo(function(info) {"
67                "window.__display_info = info;})")
68        utils.wait_for_value(lambda: (
69                extension.EvaluateJavaScript("window.__display_info") != None),
70                expected_value=True)
71        return extension.EvaluateJavaScript("window.__display_info")
72
73
74    @_retry_chrome_call
75    def get_window_info(self):
76        """Gets the current window info from Chrome.system.window API.
77
78        @return a dict for the information of the current window.
79        """
80
81        extension = self._chrome.autotest_ext
82        if not extension:
83            raise RuntimeError('Autotest extension not found')
84        extension.ExecuteJavaScript('window.__window_info = null;')
85        extension.ExecuteJavaScript(
86                "chrome.windows.getCurrent(function(info) {"
87                "window.__window_info = info;})")
88        utils.wait_for_value(lambda: (
89                extension.EvaluateJavaScript("window.__window_info") != None),
90                expected_value=True)
91        return extension.EvaluateJavaScript("window.__window_info")
92
93
94    def _wait_for_display_options_to_appear(self, tab, display_index,
95                                            timeout=16):
96        """Waits for option.DisplayOptions to appear.
97
98        The function waits until options.DisplayOptions appears or is timed out
99                after the specified time.
100
101        @param tab: the tab where the display options dialog is shown.
102        @param display_index: index of the display.
103        @param timeout: time wait for display options appear.
104
105        @raise RuntimeError when display_index is out of range
106        @raise TimeoutException when the operation is timed out.
107        """
108
109        tab.WaitForJavaScriptExpression(
110                    "typeof options !== 'undefined' &&"
111                    "typeof options.DisplayOptions !== 'undefined' &&"
112                    "typeof options.DisplayOptions.instance_ !== 'undefined' &&"
113                    "typeof options.DisplayOptions.instance_"
114                    "       .displays_ !== 'undefined'", timeout)
115
116        if not tab.EvaluateJavaScript(
117                    "options.DisplayOptions.instance_.displays_.length > %d"
118                    % (display_index)):
119            raise RuntimeError('Display index out of range: '
120                    + str(tab.EvaluateJavaScript(
121                    "options.DisplayOptions.instance_.displays_.length")))
122
123        tab.WaitForJavaScriptExpression(
124                "typeof options.DisplayOptions.instance_"
125                "         .displays_[%(index)d] !== 'undefined' &&"
126                "typeof options.DisplayOptions.instance_"
127                "         .displays_[%(index)d].id !== 'undefined' &&"
128                "typeof options.DisplayOptions.instance_"
129                "         .displays_[%(index)d].resolutions !== 'undefined'"
130                % {'index': display_index}, timeout)
131
132
133    def get_display_modes(self, display_index):
134        """Gets all the display modes for the specified display.
135
136        The modes are obtained from chrome://settings-frame/display via
137        telemetry.
138
139        @param display_index: index of the display to get modes from.
140
141        @return: A list of DisplayMode dicts.
142
143        @raise TimeoutException when the operation is timed out.
144        """
145        try:
146            tab = self._load_url('chrome://settings-frame/display')
147            self._wait_for_display_options_to_appear(tab, display_index)
148            return tab.EvaluateJavaScript(
149                    "options.DisplayOptions.instance_"
150                    "         .displays_[%(index)d].resolutions"
151                    % {'index': display_index})
152        finally:
153            self.close_tab()
154
155
156    def get_available_resolutions(self, display_index):
157        """Gets the resolutions from the specified display.
158
159        @return a list of (width, height) tuples.
160        """
161        # Start from M38 (refer to http://codereview.chromium.org/417113012),
162        # a DisplayMode dict contains 'originalWidth'/'originalHeight'
163        # in addition to 'width'/'height'.
164        # OriginalWidth/originalHeight is what is supported by the display
165        # while width/height is what is shown to users in the display setting.
166        modes = self.get_display_modes(display_index)
167        if modes:
168            if 'originalWidth' in modes[0]:
169                # M38 or newer
170                # TODO(tingyuan): fix loading image for cases where original
171                #                 width/height is different from width/height.
172                return list(set([(mode['originalWidth'], mode['originalHeight'])
173                        for mode in modes]))
174
175        # pre-M38
176        return [(mode['width'], mode['height']) for mode in modes
177                if 'scale' not in mode]
178
179
180    def get_first_external_display_index(self):
181        """Gets the first external display index.
182
183        @return the index of the first external display; False if not found.
184        """
185        # Get the first external and enabled display
186        for index, display in enumerate(self.get_display_info()):
187            if display['isEnabled'] and not display['isInternal']:
188                return index
189        return False
190
191
192    def set_resolution(self, display_index, width, height, timeout=3):
193        """Sets the resolution of the specified display.
194
195        @param display_index: index of the display to set resolution for.
196        @param width: width of the resolution
197        @param height: height of the resolution
198        @param timeout: maximal time in seconds waiting for the new resolution
199                to settle in.
200        @raise TimeoutException when the operation is timed out.
201        """
202
203        try:
204            tab = self._load_url('chrome://settings-frame/display')
205            self._wait_for_display_options_to_appear(tab, display_index)
206
207            tab.ExecuteJavaScript(
208                    # Start from M38 (refer to CR:417113012), a DisplayMode dict
209                    # contains 'originalWidth'/'originalHeight' in addition to
210                    # 'width'/'height'. OriginalWidth/originalHeight is what is
211                    # supported by the display while width/height is what is
212                    # shown to users in the display setting.
213                    """
214                    var display = options.DisplayOptions.instance_
215                              .displays_[%(index)d];
216                    var modes = display.resolutions;
217                    for (index in modes) {
218                        var mode = modes[index];
219                        if (mode.originalWidth == %(width)d &&
220                                mode.originalHeight == %(height)d) {
221                            chrome.send('setDisplayMode', [display.id, mode]);
222                            break;
223                        }
224                    }
225                    """
226                    % {'index': display_index, 'width': width, 'height': height}
227            )
228
229            def _get_selected_resolution():
230                modes = tab.EvaluateJavaScript(
231                        """
232                        options.DisplayOptions.instance_
233                                 .displays_[%(index)d].resolutions
234                        """
235                        % {'index': display_index})
236                for mode in modes:
237                    if mode['selected']:
238                        return (mode['originalWidth'], mode['originalHeight'])
239
240            # TODO(tingyuan):
241            # Support for multiple external monitors (i.e. for chromebox)
242            end_time = time.time() + timeout
243            while time.time() < end_time:
244                r = _get_selected_resolution()
245                if (width, height) == (r[0], r[1]):
246                    return True
247                time.sleep(0.1)
248            raise TimeoutException('Failed to change resolution to %r (%r'
249                                   ' detected)' % ((width, height), r))
250        finally:
251            self.close_tab()
252
253
254    @_retry_display_call
255    def get_external_resolution(self):
256        """Gets the resolution of the external screen.
257
258        @return The resolution tuple (width, height)
259        """
260        return graphics_utils.get_external_resolution()
261
262    def get_internal_resolution(self):
263        """Gets the resolution of the internal screen.
264
265        @return The resolution tuple (width, height) or None if internal screen
266                is not available
267        """
268        for display in self.get_display_info():
269            if display['isInternal']:
270                bounds = display['bounds']
271                return (bounds['width'], bounds['height'])
272        return None
273
274
275    def set_content_protection(self, state):
276        """Sets the content protection of the external screen.
277
278        @param state: One of the states 'Undesired', 'Desired', or 'Enabled'
279        """
280        connector = self.get_external_connector_name()
281        graphics_utils.set_content_protection(connector, state)
282
283
284    def get_content_protection(self):
285        """Gets the state of the content protection.
286
287        @param output: The output name as a string.
288        @return: A string of the state, like 'Undesired', 'Desired', or 'Enabled'.
289                 False if not supported.
290        """
291        connector = self.get_external_connector_name()
292        return graphics_utils.get_content_protection(connector)
293
294
295    def get_external_crtc(self):
296        """Gets the external crtc.
297
298        @return The id of the external crtc."""
299        return graphics_utils.get_external_crtc()
300
301
302    def get_internal_crtc(self):
303        """Gets the internal crtc.
304
305        @retrun The id of the internal crtc."""
306        return graphics_utils.get_internal_crtc()
307
308
309    def get_output_rect(self, output):
310        """Gets the size and position of the given output on the screen buffer.
311
312        @param output: The output name as a string.
313
314        @return A tuple of the rectangle (width, height, fb_offset_x,
315                fb_offset_y) of ints.
316        """
317        regexp = re.compile(
318                r'^([-A-Za-z0-9]+)\s+connected\s+(\d+)x(\d+)\+(\d+)\+(\d+)',
319                re.M)
320        match = regexp.findall(graphics_utils.call_xrandr())
321        for m in match:
322            if m[0] == output:
323                return (int(m[1]), int(m[2]), int(m[3]), int(m[4]))
324        return (0, 0, 0, 0)
325
326
327    def take_internal_screenshot(self, path):
328        if utils.is_freon():
329            self.take_screenshot_crtc(path, self.get_internal_crtc())
330        else:
331            output = self.get_internal_connector_name()
332            box = self.get_output_rect(output)
333            graphics_utils.take_screenshot_crop_x(path, box)
334            return output, box  # for logging/debugging
335
336
337    def take_external_screenshot(self, path):
338        if utils.is_freon():
339            self.take_screenshot_crtc(path, self.get_external_crtc())
340        else:
341            output = self.get_external_connector_name()
342            box = self.get_output_rect(output)
343            graphics_utils.take_screenshot_crop_x(path, box)
344            return output, box  # for logging/debugging
345
346
347    def take_screenshot_crtc(self, path, id):
348        """Captures the DUT screenshot, use id for selecting screen.
349
350        @param path: path to image file.
351        @param id: The id of the crtc to screenshot.
352        """
353
354        graphics_utils.take_screenshot_crop(path, crtc_id=id)
355        return True
356
357
358    def take_tab_screenshot(self, output_path, url_pattern=None):
359        """Takes a screenshot of the tab specified by the given url pattern.
360
361        @param output_path: A path of the output file.
362        @param url_pattern: A string of url pattern used to search for tabs.
363                            Default is to look for .svg image.
364        """
365        if url_pattern is None:
366            # If no URL pattern is provided, defaults to capture the first
367            # tab that shows SVG image.
368            url_pattern = '.svg'
369
370        tabs = self._browser.tabs
371        for i in xrange(0, len(tabs)):
372            if url_pattern in tabs[i].url:
373                data = tabs[i].Screenshot(timeout=5)
374                # Flip the colors from BGR to RGB.
375                data = numpy.fliplr(data.reshape(-1, 3)).reshape(data.shape)
376                data.tofile(output_path)
377                break
378        return True
379
380
381    def toggle_mirrored(self):
382        """Toggles mirrored."""
383        graphics_utils.screen_toggle_mirrored()
384        return True
385
386
387    def hide_cursor(self):
388        """Hides mouse cursor."""
389        graphics_utils.hide_cursor()
390        return True
391
392
393    def is_mirrored_enabled(self):
394        """Checks the mirrored state.
395
396        @return True if mirrored mode is enabled.
397        """
398        return bool(self.get_display_info()[0]['mirroringSourceId'])
399
400
401    def set_mirrored(self, is_mirrored):
402        """Sets mirrored mode.
403
404        @param is_mirrored: True or False to indicate mirrored state.
405        @return True if success, False otherwise.
406        """
407        # TODO: Do some experiments to minimize waiting time after toggling.
408        retries = 3
409        while self.is_mirrored_enabled() != is_mirrored and retries > 0:
410            self.toggle_mirrored()
411            time.sleep(3)
412            retries -= 1
413        return self.is_mirrored_enabled() == is_mirrored
414
415
416    def is_display_primary(self, internal=True):
417        """Checks if internal screen is primary display.
418
419        @param internal: is internal/external screen primary status requested
420        @return boolean True if internal display is primary.
421        """
422        for info in self.get_display_info():
423            if info['isInternal'] == internal and info['isPrimary']:
424                return True
425        return False
426
427
428    def suspend_resume(self, suspend_time=10):
429        """Suspends the DUT for a given time in second.
430
431        @param suspend_time: Suspend time in second.
432        """
433        sys_power.do_suspend(suspend_time)
434        return True
435
436
437    def suspend_resume_bg(self, suspend_time=10):
438        """Suspends the DUT for a given time in second in the background.
439
440        @param suspend_time: Suspend time in second.
441        """
442        process = multiprocessing.Process(target=self.suspend_resume,
443                                          args=(suspend_time,))
444        process.start()
445        return True
446
447
448    @_retry_display_call
449    def get_external_connector_name(self):
450        """Gets the name of the external output connector.
451
452        @return The external output connector name as a string, if any.
453                Otherwise, return False.
454        """
455        return graphics_utils.get_external_connector_name()
456
457
458    def get_internal_connector_name(self):
459        """Gets the name of the internal output connector.
460
461        @return The internal output connector name as a string, if any.
462                Otherwise, return False.
463        """
464        return graphics_utils.get_internal_connector_name()
465
466
467    def wait_external_display_connected(self, display):
468        """Waits for the specified external display to be connected.
469
470        @param display: The display name as a string, like 'HDMI1', or
471                        False if no external display is expected.
472        @return: True if display is connected; False otherwise.
473        """
474        result = utils.wait_for_value(self.get_external_connector_name,
475                                      expected_value=display)
476        return result == display
477
478
479    @_retry_chrome_call
480    def move_to_display(self, display_index):
481        """Moves the current window to the indicated display.
482
483        @param display_index: The index of the indicated display.
484        @return True if success, False otherwise.
485        """
486        display_info = self.get_display_info()
487        if (display_index is False or
488            display_index not in xrange(0, len(display_info)) or
489            not display_info[display_index]['isEnabled']):
490            raise RuntimeError('Cannot find the indicated display')
491        target_bounds = display_info[display_index]['bounds']
492        extension = self._chrome.autotest_ext
493        if not extension:
494            raise RuntimeError('Autotest extension not found')
495        # If the area of bounds is empty (here we achieve this by setting
496        # width and height to zero), the window_sizer will automatically
497        # determine an area which is visible and fits on the screen.
498        # For more details, see chrome/browser/ui/window_sizer.cc
499        # Without setting state to 'normal', if the current state is
500        # 'minimized', 'maximized' or 'fullscreen', the setting of
501        # 'left', 'top', 'width' and 'height' will be ignored.
502        # For more details, see chrome/browser/extensions/api/tabs/tabs_api.cc
503        extension.ExecuteJavaScript(
504                """
505                var __status = 'Running';
506                chrome.windows.update(
507                        chrome.windows.WINDOW_ID_CURRENT,
508                        {left: %d, top: %d, width: 0, height: 0,
509                         state: 'normal'},
510                        function() { __status = 'Done'; });
511                """
512                % (target_bounds['left'], target_bounds['top'])
513        )
514        utils.wait_for_value(lambda: (
515                extension.EvaluateJavaScript('__status') == 'Done'),
516                expected_value=True)
517        return extension.EvaluateJavaScript('__status') == 'Done'
518
519
520    def toggle_fullscreen(self):
521        """Toggles mirrored."""
522        graphics_utils.screen_toggle_fullscreen()
523        return True
524
525
526    def is_fullscreen_enabled(self):
527        """Checks the fullscreen state.
528
529        @return True if fullscreen mode is enabled.
530        """
531        return self.get_window_info()['state'] == 'fullscreen'
532
533
534    def set_fullscreen(self, is_fullscreen):
535        """Sets the current window to full screen.
536
537        @param is_fullscreen: True or False to indicate fullscreen state.
538        @return True if success, False otherwise.
539        """
540        # TODO: Do some experiments to minimize waiting time after toggling.
541        retries = 3
542        while self.is_fullscreen_enabled() != is_fullscreen and retries > 0:
543            self.toggle_fullscreen()
544            time.sleep(3)
545            retries -= 1
546        return self.is_fullscreen_enabled() == is_fullscreen
547
548
549    @_retry_chrome_call
550    def _load_url(self, url):
551        """Loads the given url in a new tab.
552
553        @param url: The url to load as a string.
554        @return: A new tab object.
555        """
556        tab = self._browser.tabs.New()
557        tab.Navigate(url)
558        tab.Activate()
559        return tab
560
561
562    def load_calibration_image(self, resolution):
563        """Load a full screen calibration image from the HTTP server.
564
565        @param resolution: A tuple (width, height) of resolution.
566        """
567        path = self.CALIBRATION_IMAGE_PATH
568        self._image_generator.generate_image(resolution[0], resolution[1], path)
569        os.chmod(path, 0644)
570        self._load_url('file://%s' % path)
571        return True
572
573
574    @_retry_chrome_call
575    def close_tab(self, index=-1):
576        """Disables fullscreen and closes the tab of the given index.
577
578        @param index: The tab index to close. Defaults to the last tab.
579        """
580        # set_fullscreen(False) is necessary here because currently there
581        # is a bug in tabs.Close(). If the current state is fullscreen and
582        # we call close_tab() without setting state back to normal, it will
583        # cancel fullscreen mode without changing system configuration, and
584        # so that the next time someone calls set_fullscreen(True), the
585        # function will find that current state is already 'fullscreen'
586        # (though it is not) and do nothing, which will break all the
587        # following tests.
588        self.set_fullscreen(False)
589        self._browser.tabs[index].Close()
590        return True
591