display_facade_native.py revision 055ada6014f9c5808c616a2d1417b3261e2e1b10
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 os
10import re
11import time
12import telemetry
13
14from autotest_lib.client.bin import utils
15from autotest_lib.client.common_lib import error
16from autotest_lib.client.common_lib.cros import retry
17from autotest_lib.client.cros import constants, sys_power
18from autotest_lib.client.cros.graphics import graphics_utils
19from autotest_lib.client.cros.multimedia import image_generator
20
21TimeoutException = telemetry.core.util.TimeoutException
22
23
24_FLAKY_CALL_RETRY_TIMEOUT_SEC = 60
25_FLAKY_CALL_RETRY_DELAY_SEC = 1
26
27_telemetry_devtools = telemetry.core.backends.chrome_inspector.devtools_http
28_retry_chrome_call = retry.retry(
29        (telemetry.core.exceptions.BrowserConnectionGoneException,
30         telemetry.core.exceptions.DevtoolsTargetCrashException,
31         _telemetry_devtools.DevToolsClientUrlError,
32         exceptions.IndexError),
33        timeout_min=_FLAKY_CALL_RETRY_TIMEOUT_SEC / 60.0,
34        delay_sec=_FLAKY_CALL_RETRY_DELAY_SEC)
35
36_retry_display_call = retry.retry(
37        (KeyError, error.CmdError),
38        timeout_min=_FLAKY_CALL_RETRY_TIMEOUT_SEC / 60.0,
39        delay_sec=_FLAKY_CALL_RETRY_DELAY_SEC)
40
41
42class DisplayFacadeNative(object):
43    """Facade to access the display-related functionality.
44
45    The methods inside this class only accept Python native types.
46    """
47
48    CALIBRATION_IMAGE_PATH = '/tmp/calibration.svg'
49
50    def __init__(self, chrome):
51        self._chrome = chrome
52        self._browser = chrome.browser
53        self._image_generator = image_generator.ImageGenerator()
54
55
56    @_retry_chrome_call
57    def get_display_info(self):
58        """Gets the display info from Chrome.system.display API.
59
60        @return array of dict for display info.
61        """
62
63        extension = self._chrome.get_extension(
64                constants.MULTIMEDIA_TEST_EXTENSION)
65        if not extension:
66            raise RuntimeError('Graphics test extension not found')
67        extension.ExecuteJavaScript('window.__display_info = null;')
68        extension.ExecuteJavaScript(
69                "chrome.system.display.getInfo(function(info) {"
70                "window.__display_info = info;})")
71        utils.wait_for_value(lambda: (
72                extension.EvaluateJavaScript("window.__display_info") != None),
73                expected_value=True)
74        return extension.EvaluateJavaScript("window.__display_info")
75
76
77    def _wait_for_display_options_to_appear(self, tab, display_index,
78                                            timeout=16):
79        """Waits for option.DisplayOptions to appear.
80
81        The function waits until options.DisplayOptions appears or is timed out
82                after the specified time.
83
84        @param tab: the tab where the display options dialog is shown.
85        @param display_index: index of the display.
86        @param timeout: time wait for display options appear.
87
88        @raise RuntimeError when display_index is out of range
89        @raise TimeoutException when the operation is timed out.
90        """
91
92        tab.WaitForJavaScriptExpression(
93                    "typeof options !== 'undefined' &&"
94                    "typeof options.DisplayOptions !== 'undefined' &&"
95                    "typeof options.DisplayOptions.instance_ !== 'undefined' &&"
96                    "typeof options.DisplayOptions.instance_"
97                    "       .displays_ !== 'undefined'", timeout)
98
99        if not tab.EvaluateJavaScript(
100                    "options.DisplayOptions.instance_.displays_.length > %d"
101                    % (display_index)):
102            raise RuntimeError('Display index out of range: '
103                    + str(tab.EvaluateJavaScript(
104                    "options.DisplayOptions.instance_.displays_.length")))
105
106        tab.WaitForJavaScriptExpression(
107                "typeof options.DisplayOptions.instance_"
108                "         .displays_[%(index)d] !== 'undefined' &&"
109                "typeof options.DisplayOptions.instance_"
110                "         .displays_[%(index)d].id !== 'undefined' &&"
111                "typeof options.DisplayOptions.instance_"
112                "         .displays_[%(index)d].resolutions !== 'undefined'"
113                % {'index': display_index}, timeout)
114
115
116    def get_display_modes(self, display_index):
117        """Gets all the display modes for the specified display.
118
119        The modes are obtained from chrome://settings-frame/display via
120        telemetry.
121
122        @param display_index: index of the display to get modes from.
123
124        @return: A list of DisplayMode dicts.
125
126        @raise TimeoutException when the operation is timed out.
127        """
128        try:
129            tab = self._load_url('chrome://settings-frame/display')
130            self._wait_for_display_options_to_appear(tab, display_index)
131            return tab.EvaluateJavaScript(
132                    "options.DisplayOptions.instance_"
133                    "         .displays_[%(index)d].resolutions"
134                    % {'index': display_index})
135        finally:
136            self.close_tab()
137
138
139    def get_available_resolutions(self, display_index):
140        """Gets the resolutions from the specified display.
141
142        @return a list of (width, height) tuples.
143        """
144        # Start from M38 (refer to http://codereview.chromium.org/417113012),
145        # a DisplayMode dict contains 'originalWidth'/'originalHeight'
146        # in addition to 'width'/'height'.
147        # OriginalWidth/originalHeight is what is supported by the display
148        # while width/height is what is shown to users in the display setting.
149        modes = self.get_display_modes(display_index)
150        if modes:
151            if 'originalWidth' in modes[0]:
152                # M38 or newer
153                # TODO(tingyuan): fix loading image for cases where original
154                #                 width/height is different from width/height.
155                return list(set([(mode['originalWidth'], mode['originalHeight'])
156                        for mode in modes]))
157
158        # pre-M38
159        return [(mode['width'], mode['height']) for mode in modes
160                if 'scale' not in mode]
161
162
163    def get_first_external_display_index(self):
164        """Gets the first external display index.
165
166        @return the index of the first external display; False if not found.
167        """
168        # Get the first external and enabled display
169        for index, display in enumerate(self.get_display_info()):
170            if display['isEnabled'] and not display['isInternal']:
171                return index
172        return False
173
174
175    def set_resolution(self, display_index, width, height, timeout=3):
176        """Sets the resolution of the specified display.
177
178        @param display_index: index of the display to set resolution for.
179        @param width: width of the resolution
180        @param height: height of the resolution
181        @param timeout: maximal time in seconds waiting for the new resolution
182                to settle in.
183        @raise TimeoutException when the operation is timed out.
184        """
185
186        try:
187            tab = self._load_url('chrome://settings-frame/display')
188            self._wait_for_display_options_to_appear(tab, display_index)
189
190            tab.ExecuteJavaScript(
191                    # Start from M38 (refer to CR:417113012), a DisplayMode dict
192                    # contains 'originalWidth'/'originalHeight' in addition to
193                    # 'width'/'height'. OriginalWidth/originalHeight is what is
194                    # supported by the display while width/height is what is
195                    # shown to users in the display setting.
196                    """
197                    var display = options.DisplayOptions.instance_
198                              .displays_[%(index)d];
199                    var modes = display.resolutions;
200                    var is_m38 = modes.length > 0
201                             && "originalWidth" in modes[0];
202                    if (is_m38) {
203                      for (index in modes) {
204                          var mode = modes[index];
205                          if (mode.originalWidth == %(width)d &&
206                                  mode.originalHeight == %(height)d) {
207                              chrome.send('setDisplayMode', [display.id, mode]);
208                              break;
209                          }
210                      }
211                    } else {
212                      chrome.send('setResolution',
213                          [display.id, %(width)d, %(height)d]);
214                    }
215                    """
216                    % {'index': display_index, 'width': width, 'height': height}
217            )
218
219            # TODO(tingyuan):
220            # Support for multiple external monitors (i.e. for chromebox)
221
222            end_time = time.time() + timeout
223            while time.time() < end_time:
224                r = self.get_output_rect(self.get_external_connector_name())
225                if (width, height) == (r[0], r[1]):
226                    return True
227                time.sleep(0.1)
228            raise TimeoutException("Failed to change resolution to %r (%r"
229                    " detected)" % ((width, height), r))
230        finally:
231            self.close_tab()
232
233
234    @_retry_display_call
235    def get_output_rect(self, output):
236        """Gets the size and position of the given output on the screen buffer.
237
238        @param output: The output name as a string.
239
240        @return A tuple of the rectangle (width, height, fb_offset_x,
241                fb_offset_y) of ints.
242        """
243        regexp = re.compile(
244                r'^([-A-Za-z0-9]+)\s+connected\s+(\d+)x(\d+)\+(\d+)\+(\d+)',
245                re.M)
246        match = regexp.findall(graphics_utils.call_xrandr())
247        for m in match:
248            if m[0] == output:
249                return (int(m[1]), int(m[2]), int(m[3]), int(m[4]))
250        return (0, 0, 0, 0)
251
252
253    def get_external_resolution(self):
254        """Gets the resolution of the external screen.
255
256        @return The resolution tuple (width, height)
257        """
258        connector = self.get_external_connector_name()
259        width, height, _, _ = self.get_output_rect(connector)
260        return (width, height)
261
262
263    def get_internal_resolution(self):
264        """Gets the resolution of the internal screen.
265
266        @return The resolution tuple (width, height)
267        """
268        connector = self.get_internal_connector_name()
269        width, height, _, _ = self.get_output_rect(connector)
270        return (width, height)
271
272
273    def set_content_protection(self, state):
274        """Sets the content protection of the external screen.
275
276        @param state: One of the states 'Undesired', 'Desired', or 'Enabled'
277        """
278        connector = self.get_external_connector_name()
279        graphics_utils.set_content_protection(connector, state)
280
281
282    def get_content_protection(self):
283        """Gets the state of the content protection.
284
285        @param output: The output name as a string.
286        @return: A string of the state, like 'Undesired', 'Desired', or 'Enabled'.
287                 False if not supported.
288        """
289        connector = self.get_external_connector_name()
290        return graphics_utils.get_content_protection(connector)
291
292
293    def take_screenshot_crop(self, path, box):
294        """Captures the DUT screenshot, use box for cropping.
295
296        @param path: path to image file.
297        @param box: 4-tuple giving the upper left and lower right coordinates.
298        """
299        graphics_utils.take_screenshot_crop(path, box)
300        return True
301
302
303    def take_tab_screenshot(self, url_pattern, output_suffix):
304        """Takes a screenshot of the tab specified by the given url pattern.
305
306        The captured screenshot is saved to:
307            /tmp/screenshot_<output_suffix>_<last_part_of_url>.png
308
309        @param url_pattern: A string of url pattern used to search for tabs.
310        @param output_suffix: A suffix appended to the file name of captured
311                PNG image.
312        """
313        if not url_pattern:
314            # If no URL pattern is provided, defaults to capture all the tabs
315            # that show PNG images.
316            url_pattern = '.png'
317
318        tabs = self._browser.tabs
319        screenshots = []
320        for i in xrange(0, len(tabs)):
321            if url_pattern in tabs[i].url:
322                screenshots.append((tabs[i].url, tabs[i].Screenshot(timeout=5)))
323
324        output_file = ('/tmp/screenshot_%s_%%s.png' % output_suffix)
325        for url, screenshot in screenshots:
326            image_filename = os.path.splitext(url.rsplit('/', 1)[-1])[0]
327            screenshot.WriteFile(output_file % image_filename)
328        return True
329
330
331    def toggle_mirrored(self):
332        """Toggles mirrored."""
333        graphics_utils.screen_toggle_mirrored()
334        return True
335
336
337    def hide_cursor(self):
338        """Hides mouse cursor."""
339        graphics_utils.hide_cursor()
340        return True
341
342
343    def is_mirrored_enabled(self):
344        """Checks the mirrored state.
345
346        @return True if mirrored mode is enabled.
347        """
348        return bool(self.get_display_info()[0]['mirroringSourceId'])
349
350
351    def set_mirrored(self, is_mirrored):
352        """Sets mirrored mode.
353
354        @param is_mirrored: True or False to indicate mirrored state.
355        """
356        retries = 3
357        while self.is_mirrored_enabled() != is_mirrored and retries > 0:
358            self.toggle_mirrored()
359            time.sleep(3)
360            retries -= 1
361        return self.is_mirrored_enabled() == is_mirrored
362
363
364    def is_display_primary(self, internal=True):
365        """Checks if internal screen is primary display.
366
367        @param internal: is internal/external screen primary status requested
368        @return boolean True if internal display is primary.
369        """
370        for info in self.get_display_info():
371            if info['isInternal'] == internal and info['isPrimary']:
372                return True
373        return False
374
375
376    def suspend_resume(self, suspend_time=10):
377        """Suspends the DUT for a given time in second.
378
379        @param suspend_time: Suspend time in second.
380        """
381        sys_power.do_suspend(suspend_time)
382        return True
383
384
385    def suspend_resume_bg(self, suspend_time=10):
386        """Suspends the DUT for a given time in second in the background.
387
388        @param suspend_time: Suspend time in second.
389        """
390        process = multiprocessing.Process(target=self.suspend_resume,
391                                          args=(suspend_time,))
392        process.start()
393        return True
394
395
396    @_retry_display_call
397    def get_external_connector_name(self):
398        """Gets the name of the external output connector.
399
400        @return The external output connector name as a string, if any.
401                Otherwise, return False.
402        """
403        return graphics_utils.get_external_connector_name()
404
405
406    def get_internal_connector_name(self):
407        """Gets the name of the internal output connector.
408
409        @return The internal output connector name as a string, if any.
410                Otherwise, return False.
411        """
412        return graphics_utils.get_internal_connector_name()
413
414
415    def wait_external_display_connected(self, display):
416        """Waits for the specified external display to be connected.
417
418        @param display: The display name as a string, like 'HDMI1', or
419                        False if no external display is expected.
420        @return: True if display is connected; False otherwise.
421        """
422        result = utils.wait_for_value(self.get_external_connector_name,
423                                      expected_value=display)
424        return result == display
425
426
427    @_retry_chrome_call
428    def _load_url(self, url):
429        """Loads the given url in a new tab.
430
431        @param url: The url to load as a string.
432        @return: A new tab object.
433        """
434        tab = self._browser.tabs.New()
435        tab.Navigate(url)
436        tab.Activate()
437        return tab
438
439
440    def load_calibration_image(self, resolution):
441        """Load a full screen calibration image from the HTTP server.
442
443        @param resolution: A tuple (width, height) of resolution.
444        """
445        path = self.CALIBRATION_IMAGE_PATH
446        self._image_generator.generate_image(resolution[0], resolution[1], path)
447        os.chmod(path, 0644)
448        self._load_url('file://%s' % path)
449        return True
450
451
452    @_retry_chrome_call
453    def close_tab(self, index=-1):
454        """Closes the tab of the given index.
455
456        @param index: The tab index to close. Defaults to the last tab.
457        """
458        self._browser.tabs[index].Close()
459        return True
460