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 multiprocessing
8import numpy
9import os
10import re
11import time
12from autotest_lib.client.bin import utils
13from autotest_lib.client.common_lib import error
14from autotest_lib.client.common_lib.cros import retry
15from autotest_lib.client.cros import constants, sys_power
16from autotest_lib.client.cros.graphics import graphics_utils
17from autotest_lib.client.cros.multimedia import facade_resource
18from autotest_lib.client.cros.multimedia import image_generator
19from telemetry.internal.browser import web_contents
20
21class TimeoutException(Exception):
22    """Timeout Exception class."""
23    pass
24
25
26_FLAKY_CALL_RETRY_TIMEOUT_SEC = 60
27_FLAKY_DISPLAY_CALL_RETRY_DELAY_SEC = 2
28
29_retry_display_call = retry.retry(
30        (KeyError, error.CmdError),
31        timeout_min=_FLAKY_CALL_RETRY_TIMEOUT_SEC / 60.0,
32        delay_sec=_FLAKY_DISPLAY_CALL_RETRY_DELAY_SEC)
33
34
35class DisplayFacadeNative(object):
36    """Facade to access the display-related functionality.
37
38    The methods inside this class only accept Python native types.
39    """
40
41    CALIBRATION_IMAGE_PATH = '/tmp/calibration.svg'
42    MINIMUM_REFRESH_RATE_EXPECTED = 25.0
43    DELAY_TIME = 3
44
45    def __init__(self, resource):
46        """Initializes a DisplayFacadeNative.
47
48        @param resource: A FacadeResource object.
49        """
50        self._resource = resource
51        self._image_generator = image_generator.ImageGenerator()
52
53
54    @facade_resource.retry_chrome_call
55    def get_display_info(self):
56        """Gets the display info from Chrome.system.display API.
57
58        @return array of dict for display info.
59        """
60        extension = self._resource.get_extension(
61                constants.MULTIMEDIA_TEST_EXTENSION)
62        extension.ExecuteJavaScript('window.__display_info = null;')
63        extension.ExecuteJavaScript(
64                "chrome.system.display.getInfo(function(info) {"
65                "window.__display_info = info;})")
66        utils.wait_for_value(lambda: (
67                extension.EvaluateJavaScript("window.__display_info") != None),
68                expected_value=True)
69        return extension.EvaluateJavaScript("window.__display_info")
70
71
72    @facade_resource.retry_chrome_call
73    def get_window_info(self):
74        """Gets the current window info from Chrome.system.window API.
75
76        @return a dict for the information of the current window.
77        """
78        extension = self._resource.get_extension()
79        extension.ExecuteJavaScript('window.__window_info = null;')
80        extension.ExecuteJavaScript(
81                "chrome.windows.getCurrent(function(info) {"
82                "window.__window_info = info;})")
83        utils.wait_for_value(lambda: (
84                extension.EvaluateJavaScript("window.__window_info") != None),
85                expected_value=True)
86        return extension.EvaluateJavaScript("window.__window_info")
87
88
89    def _wait_for_display_options_to_appear(self, tab, display_index,
90                                            timeout=16):
91        """Waits for option.DisplayOptions to appear.
92
93        The function waits until options.DisplayOptions appears or is timed out
94                after the specified time.
95
96        @param tab: the tab where the display options dialog is shown.
97        @param display_index: index of the display.
98        @param timeout: time wait for display options appear.
99
100        @raise RuntimeError when display_index is out of range
101        @raise TimeoutException when the operation is timed out.
102        """
103
104        tab.WaitForJavaScriptCondition(
105                    "typeof options !== 'undefined' &&"
106                    "typeof options.DisplayOptions !== 'undefined' &&"
107                    "typeof options.DisplayOptions.instance_ !== 'undefined' &&"
108                    "typeof options.DisplayOptions.instance_"
109                    "       .displays_ !== 'undefined'", timeout=timeout)
110
111        if not tab.EvaluateJavaScript(
112                    "options.DisplayOptions.instance_.displays_.length > %d"
113                    % (display_index)):
114            raise RuntimeError('Display index out of range: '
115                    + str(tab.EvaluateJavaScript(
116                    "options.DisplayOptions.instance_.displays_.length")))
117
118        tab.WaitForJavaScriptCondition(
119                "typeof options.DisplayOptions.instance_"
120                "         .displays_[%(index)d] !== 'undefined' &&"
121                "typeof options.DisplayOptions.instance_"
122                "         .displays_[%(index)d].id !== 'undefined' &&"
123                "typeof options.DisplayOptions.instance_"
124                "         .displays_[%(index)d].resolutions !== 'undefined'"
125                % {'index': display_index}, timeout=timeout)
126
127
128    def _evaluate_display_expression(self, expression, display_index):
129        """Evaluate an expression on Chrome display settings page.
130
131        The result of the expression is obtained from
132        chrome://settings-frame/display via telemetry.
133
134        @param expression: Javascript expression.
135        @param display_index: index of the display to get modes from.
136
137        @return: A object of the result.
138
139        @raise TimeoutException when the operation is timed out.
140        """
141        try:
142            tab_descriptor = self.load_url('chrome://settings-frame/display')
143            tab = self._resource.get_tab_by_descriptor(tab_descriptor)
144            self._wait_for_display_options_to_appear(tab, display_index)
145            return tab.EvaluateJavaScript(expression % {'index': display_index})
146        finally:
147            self.close_tab(tab_descriptor)
148
149
150    def get_display_modes(self, display_index):
151        """Gets all the display modes for the specified display.
152
153        @param display_index: index of the display to get modes from.
154
155        @return: A list of DisplayMode dicts.
156        """
157        return self._evaluate_display_expression(
158                "options.DisplayOptions.instance_"
159                "         .displays_[%(index)d].resolutions",
160                display_index)
161
162
163    def get_display_rotation(self, display_index):
164        """Gets the display rotation for the specified display.
165
166        @param display_index: index of the display to get modes from.
167
168        @return: Degree of rotation.
169        """
170        return self._evaluate_display_expression(
171                "options.DisplayOptions.instance_"
172                "         .displays_[%(index)d].rotation",
173                display_index)
174
175
176    def set_display_rotation(self, display_index, rotation,
177                             delay_before_rotation=0, delay_after_rotation=0):
178        """Sets the display rotation for the specified display.
179
180        @param display_index: index of the display to get modes from.
181        @param rotation: degree of rotation
182        @param delay_before_rotation: time in second for delay before rotation
183        @param delay_after_rotation: time in second for delay after rotation
184        """
185        try:
186            tab_descriptor = self.load_url('chrome://settings-frame/display')
187            tab = self._resource.get_tab_by_descriptor(tab_descriptor)
188            self._wait_for_display_options_to_appear(tab, display_index)
189
190            # Hide the typing cursor to reduce interference.
191            self.hide_typing_cursor()
192
193            time.sleep(delay_before_rotation)
194            tab.ExecuteJavaScript(
195                    """
196                    var display = options.DisplayOptions.instance_
197                            .displays_[%(index)d];
198                    chrome.send('setRotation', [display.id, %(rotation)d]);
199                    """
200                    % {'index': display_index, 'rotation': rotation}
201            )
202            time.sleep(delay_after_rotation)
203        finally:
204            self.close_tab(tab_descriptor)
205
206
207    def get_available_resolutions(self, display_index):
208        """Gets the resolutions from the specified display.
209
210        @return a list of (width, height) tuples.
211        """
212        # Start from M38 (refer to http://codereview.chromium.org/417113012),
213        # a DisplayMode dict contains 'originalWidth'/'originalHeight'
214        # in addition to 'width'/'height'.
215        # OriginalWidth/originalHeight is what is supported by the display
216        # while width/height is what is shown to users in the display setting.
217        modes = self.get_display_modes(display_index)
218        if modes:
219            if 'originalWidth' in modes[0]:
220                # M38 or newer
221                # TODO(tingyuan): fix loading image for cases where original
222                #                 width/height is different from width/height.
223                return list(set([(mode['originalWidth'], mode['originalHeight'])
224                        for mode in modes]))
225
226        # pre-M38
227        return [(mode['width'], mode['height']) for mode in modes
228                if 'scale' not in mode]
229
230
231    def get_first_external_display_index(self):
232        """Gets the first external display index.
233
234        @return the index of the first external display; False if not found.
235        """
236        # Get the first external and enabled display
237        for index, display in enumerate(self.get_display_info()):
238            if display['isEnabled'] and not display['isInternal']:
239                return index
240        return False
241
242
243    def set_resolution(self, display_index, width, height, timeout=3):
244        """Sets the resolution of the specified display.
245
246        @param display_index: index of the display to set resolution for.
247        @param width: width of the resolution
248        @param height: height of the resolution
249        @param timeout: maximal time in seconds waiting for the new resolution
250                to settle in.
251        @raise TimeoutException when the operation is timed out.
252        """
253
254        try:
255            tab_descriptor = self.load_url('chrome://settings-frame/display')
256            tab = self._resource.get_tab_by_descriptor(tab_descriptor)
257            self._wait_for_display_options_to_appear(tab, display_index)
258
259            tab.ExecuteJavaScript(
260                    # Start from M38 (refer to CR:417113012), a DisplayMode dict
261                    # contains 'originalWidth'/'originalHeight' in addition to
262                    # 'width'/'height'. OriginalWidth/originalHeight is what is
263                    # supported by the display while width/height is what is
264                    # shown to users in the display setting.
265                    """
266                    var display = options.DisplayOptions.instance_
267                              .displays_[%(index)d];
268                    var modes = display.resolutions;
269                    for (index in modes) {
270                        var mode = modes[index];
271                        if (mode.originalWidth == %(width)d &&
272                                mode.originalHeight == %(height)d) {
273                            chrome.send('setDisplayMode', [display.id, mode]);
274                            break;
275                        }
276                    }
277                    """
278                    % {'index': display_index, 'width': width, 'height': height}
279            )
280
281            def _get_selected_resolution():
282                modes = tab.EvaluateJavaScript(
283                        """
284                        options.DisplayOptions.instance_
285                                 .displays_[%(index)d].resolutions
286                        """
287                        % {'index': display_index})
288                for mode in modes:
289                    if mode['selected']:
290                        return (mode['originalWidth'], mode['originalHeight'])
291
292            # TODO(tingyuan):
293            # Support for multiple external monitors (i.e. for chromebox)
294            end_time = time.time() + timeout
295            while time.time() < end_time:
296                r = _get_selected_resolution()
297                if (width, height) == (r[0], r[1]):
298                    return True
299                time.sleep(0.1)
300            raise TimeoutException('Failed to change resolution to %r (%r'
301                                   ' detected)' % ((width, height), r))
302        finally:
303            self.close_tab(tab_descriptor)
304
305
306    @_retry_display_call
307    def get_external_resolution(self):
308        """Gets the resolution of the external screen.
309
310        @return The resolution tuple (width, height)
311        """
312        return graphics_utils.get_external_resolution()
313
314    def get_internal_resolution(self):
315        """Gets the resolution of the internal screen.
316
317        @return The resolution tuple (width, height) or None if internal screen
318                is not available
319        """
320        for display in self.get_display_info():
321            if display['isInternal']:
322                bounds = display['bounds']
323                return (bounds['width'], bounds['height'])
324        return None
325
326
327    def set_content_protection(self, state):
328        """Sets the content protection of the external screen.
329
330        @param state: One of the states 'Undesired', 'Desired', or 'Enabled'
331        """
332        connector = self.get_external_connector_name()
333        graphics_utils.set_content_protection(connector, state)
334
335
336    def get_content_protection(self):
337        """Gets the state of the content protection.
338
339        @param output: The output name as a string.
340        @return: A string of the state, like 'Undesired', 'Desired', or 'Enabled'.
341                 False if not supported.
342        """
343        connector = self.get_external_connector_name()
344        return graphics_utils.get_content_protection(connector)
345
346
347    def get_external_crtc(self):
348        """Gets the external crtc.
349
350        @return The id of the external crtc."""
351        return graphics_utils.get_external_crtc()
352
353
354    def get_internal_crtc(self):
355        """Gets the internal crtc.
356
357        @retrun The id of the internal crtc."""
358        return graphics_utils.get_internal_crtc()
359
360
361    def get_output_rect(self, output):
362        """Gets the size and position of the given output on the screen buffer.
363
364        @param output: The output name as a string.
365
366        @return A tuple of the rectangle (width, height, fb_offset_x,
367                fb_offset_y) of ints.
368        """
369        regexp = re.compile(
370                r'^([-A-Za-z0-9]+)\s+connected\s+(\d+)x(\d+)\+(\d+)\+(\d+)',
371                re.M)
372        match = regexp.findall(graphics_utils.call_xrandr())
373        for m in match:
374            if m[0] == output:
375                return (int(m[1]), int(m[2]), int(m[3]), int(m[4]))
376        return (0, 0, 0, 0)
377
378
379    def take_internal_screenshot(self, path):
380        """Takes internal screenshot.
381
382        @param path: path to image file.
383        """
384        if utils.is_freon():
385            self.take_screenshot_crtc(path, self.get_internal_crtc())
386        else:
387            output = self.get_internal_connector_name()
388            box = self.get_output_rect(output)
389            graphics_utils.take_screenshot_crop_x(path, box)
390            return output, box  # for logging/debugging
391
392
393    def take_external_screenshot(self, path):
394        """Takes external screenshot.
395
396        @param path: path to image file.
397        """
398        if utils.is_freon():
399            self.take_screenshot_crtc(path, self.get_external_crtc())
400        else:
401            output = self.get_external_connector_name()
402            box = self.get_output_rect(output)
403            graphics_utils.take_screenshot_crop_x(path, box)
404            return output, box  # for logging/debugging
405
406
407    def take_screenshot_crtc(self, path, id):
408        """Captures the DUT screenshot, use id for selecting screen.
409
410        @param path: path to image file.
411        @param id: The id of the crtc to screenshot.
412        """
413
414        graphics_utils.take_screenshot_crop(path, crtc_id=id)
415        return True
416
417
418    def take_tab_screenshot(self, output_path, url_pattern=None):
419        """Takes a screenshot of the tab specified by the given url pattern.
420
421        @param output_path: A path of the output file.
422        @param url_pattern: A string of url pattern used to search for tabs.
423                            Default is to look for .svg image.
424        """
425        if url_pattern is None:
426            # If no URL pattern is provided, defaults to capture the first
427            # tab that shows SVG image.
428            url_pattern = '.svg'
429
430        tabs = self._resource.get_tabs()
431        for i in xrange(0, len(tabs)):
432            if url_pattern in tabs[i].url:
433                data = tabs[i].Screenshot(timeout=5)
434                # Flip the colors from BGR to RGB.
435                data = numpy.fliplr(data.reshape(-1, 3)).reshape(data.shape)
436                data.tofile(output_path)
437                break
438        return True
439
440
441    def toggle_mirrored(self):
442        """Toggles mirrored."""
443        graphics_utils.screen_toggle_mirrored()
444        return True
445
446
447    def hide_cursor(self):
448        """Hides mouse cursor."""
449        graphics_utils.hide_cursor()
450        return True
451
452
453    def hide_typing_cursor(self):
454        """Hides typing cursor."""
455        graphics_utils.hide_typing_cursor()
456        return True
457
458
459    def is_mirrored_enabled(self):
460        """Checks the mirrored state.
461
462        @return True if mirrored mode is enabled.
463        """
464        return bool(self.get_display_info()[0]['mirroringSourceId'])
465
466
467    def set_mirrored(self, is_mirrored):
468        """Sets mirrored mode.
469
470        @param is_mirrored: True or False to indicate mirrored state.
471        @return True if success, False otherwise.
472        """
473        if self.is_mirrored_enabled() == is_mirrored:
474            return True
475
476        retries = 4
477        while retries > 0:
478            self.toggle_mirrored()
479            result = utils.wait_for_value(self.is_mirrored_enabled,
480                                          expected_value=is_mirrored,
481                                          timeout_sec=3)
482            if result == is_mirrored:
483                return True
484            retries -= 1
485        return False
486
487
488    def is_display_primary(self, internal=True):
489        """Checks if internal screen is primary display.
490
491        @param internal: is internal/external screen primary status requested
492        @return boolean True if internal display is primary.
493        """
494        for info in self.get_display_info():
495            if info['isInternal'] == internal and info['isPrimary']:
496                return True
497        return False
498
499
500    def suspend_resume(self, suspend_time=10):
501        """Suspends the DUT for a given time in second.
502
503        @param suspend_time: Suspend time in second.
504        """
505        sys_power.do_suspend(suspend_time)
506        return True
507
508
509    def suspend_resume_bg(self, suspend_time=10):
510        """Suspends the DUT for a given time in second in the background.
511
512        @param suspend_time: Suspend time in second.
513        """
514        process = multiprocessing.Process(target=self.suspend_resume,
515                                          args=(suspend_time,))
516        process.start()
517        return True
518
519
520    @_retry_display_call
521    def get_external_connector_name(self):
522        """Gets the name of the external output connector.
523
524        @return The external output connector name as a string, if any.
525                Otherwise, return False.
526        """
527        return graphics_utils.get_external_connector_name()
528
529
530    def get_internal_connector_name(self):
531        """Gets the name of the internal output connector.
532
533        @return The internal output connector name as a string, if any.
534                Otherwise, return False.
535        """
536        return graphics_utils.get_internal_connector_name()
537
538
539    def wait_external_display_connected(self, display):
540        """Waits for the specified external display to be connected.
541
542        @param display: The display name as a string, like 'HDMI1', or
543                        False if no external display is expected.
544        @return: True if display is connected; False otherwise.
545        """
546        result = utils.wait_for_value(self.get_external_connector_name,
547                                      expected_value=display)
548        return result == display
549
550
551    @facade_resource.retry_chrome_call
552    def move_to_display(self, display_index):
553        """Moves the current window to the indicated display.
554
555        @param display_index: The index of the indicated display.
556        @return True if success.
557
558        @raise TimeoutException if it fails.
559        """
560        display_info = self.get_display_info()
561        if (display_index is False or
562            display_index not in xrange(0, len(display_info)) or
563            not display_info[display_index]['isEnabled']):
564            raise RuntimeError('Cannot find the indicated display')
565        target_bounds = display_info[display_index]['bounds']
566
567        extension = self._resource.get_extension()
568        # If the area of bounds is empty (here we achieve this by setting
569        # width and height to zero), the window_sizer will automatically
570        # determine an area which is visible and fits on the screen.
571        # For more details, see chrome/browser/ui/window_sizer.cc
572        # Without setting state to 'normal', if the current state is
573        # 'minimized', 'maximized' or 'fullscreen', the setting of
574        # 'left', 'top', 'width' and 'height' will be ignored.
575        # For more details, see chrome/browser/extensions/api/tabs/tabs_api.cc
576        extension.ExecuteJavaScript(
577                """
578                var __status = 'Running';
579                chrome.windows.update(
580                        chrome.windows.WINDOW_ID_CURRENT,
581                        {left: %d, top: %d, width: 0, height: 0,
582                         state: 'normal'},
583                        function(info) {
584                            if (info.left == %d && info.top == %d &&
585                                info.state == 'normal')
586                                __status = 'Done'; });
587                """
588                % (target_bounds['left'], target_bounds['top'],
589                   target_bounds['left'], target_bounds['top'])
590        )
591        extension.WaitForJavaScriptCondition(
592                "__status == 'Done'",
593                timeout=web_contents.DEFAULT_WEB_CONTENTS_TIMEOUT)
594        return True
595
596
597    def is_fullscreen_enabled(self):
598        """Checks the fullscreen state.
599
600        @return True if fullscreen mode is enabled.
601        """
602        return self.get_window_info()['state'] == 'fullscreen'
603
604
605    def set_fullscreen(self, is_fullscreen):
606        """Sets the current window to full screen.
607
608        @param is_fullscreen: True or False to indicate fullscreen state.
609        @return True if success, False otherwise.
610        """
611        extension = self._resource.get_extension()
612        if not extension:
613            raise RuntimeError('Autotest extension not found')
614
615        if is_fullscreen:
616            window_state = "fullscreen"
617        else:
618            window_state = "normal"
619        extension.ExecuteJavaScript(
620                """
621                var __status = 'Running';
622                chrome.windows.update(
623                        chrome.windows.WINDOW_ID_CURRENT,
624                        {state: '%s'},
625                        function() { __status = 'Done'; });
626                """
627                % window_state)
628        utils.wait_for_value(lambda: (
629                extension.EvaluateJavaScript('__status') == 'Done'),
630                expected_value=True)
631        return self.is_fullscreen_enabled() == is_fullscreen
632
633
634    def load_url(self, url):
635        """Loads the given url in a new tab. The new tab will be active.
636
637        @param url: The url to load as a string.
638        @return a str, the tab descriptor of the opened tab.
639        """
640        return self._resource.load_url(url)
641
642
643    def load_calibration_image(self, resolution):
644        """Opens a new tab and loads a full screen calibration
645           image from the HTTP server.
646
647        @param resolution: A tuple (width, height) of resolution.
648        @return a str, the tab descriptor of the opened tab.
649        """
650        path = self.CALIBRATION_IMAGE_PATH
651        self._image_generator.generate_image(resolution[0], resolution[1], path)
652        os.chmod(path, 0644)
653        tab_descriptor = self.load_url('file://%s' % path)
654        return tab_descriptor
655
656
657    def load_color_sequence(self, tab_descriptor, color_sequence):
658        """Displays a series of colors on full screen on the tab.
659        tab_descriptor is returned by any open tab API of display facade.
660        e.g.,
661        tab_descriptor = load_url('about:blank')
662        load_color_sequence(tab_descriptor, color)
663
664        @param tab_descriptor: Indicate which tab to test.
665        @param color_sequence: An integer list for switching colors.
666        @return A list of the timestamp for each switch.
667        """
668        tab = self._resource.get_tab_by_descriptor(tab_descriptor)
669        color_sequence_for_java_script = (
670                'var color_sequence = [' +
671                ','.join("'#%06X'" % x for x in color_sequence) +
672                '];')
673        # Paints are synchronized to the fresh rate of the screen by
674        # window.requestAnimationFrame.
675        tab.ExecuteJavaScript(color_sequence_for_java_script + """
676            function render(timestamp) {
677                window.timestamp_list.push(timestamp);
678                if (window.count < color_sequence.length) {
679                    document.body.style.backgroundColor =
680                            color_sequence[count];
681                    window.count++;
682                    window.requestAnimationFrame(render);
683                }
684            }
685            window.count = 0;
686            window.timestamp_list = [];
687            window.requestAnimationFrame(render);
688            """)
689
690        # Waiting time is decided by following concerns:
691        # 1. MINIMUM_REFRESH_RATE_EXPECTED: the minimum refresh rate
692        #    we expect it to be. Real refresh rate is related to
693        #    not only hardware devices but also drivers and browsers.
694        #    Most graphics devices support at least 60fps for a single
695        #    monitor, and under mirror mode, since the both frames
696        #    buffers need to be updated for an input frame, the refresh
697        #    rate will decrease by half, so here we set it to be a
698        #    little less than 30 (= 60/2) to make it more tolerant.
699        # 2. DELAY_TIME: extra wait time for timeout.
700        tab.WaitForJavaScriptCondition(
701                'window.count == color_sequence.length',
702                timeout=(
703                    (len(color_sequence) / self.MINIMUM_REFRESH_RATE_EXPECTED)
704                    + self.DELAY_TIME))
705        return tab.EvaluateJavaScript("window.timestamp_list")
706
707
708    def close_tab(self, tab_descriptor):
709        """Disables fullscreen and closes the tab of the given tab descriptor.
710        tab_descriptor is returned by any open tab API of display facade.
711        e.g.,
712        1.
713        tab_descriptor = load_url(url)
714        close_tab(tab_descriptor)
715
716        2.
717        tab_descriptor = load_calibration_image(resolution)
718        close_tab(tab_descriptor)
719
720        @param tab_descriptor: Indicate which tab to be closed.
721        """
722        # set_fullscreen(False) is necessary here because currently there
723        # is a bug in tabs.Close(). If the current state is fullscreen and
724        # we call close_tab() without setting state back to normal, it will
725        # cancel fullscreen mode without changing system configuration, and
726        # so that the next time someone calls set_fullscreen(True), the
727        # function will find that current state is already 'fullscreen'
728        # (though it is not) and do nothing, which will break all the
729        # following tests.
730        self.set_fullscreen(False)
731        self._resource.close_tab(tab_descriptor)
732        return True
733