blob: e68a01eb81e5e7a69af6f5619b2ee0bddecb28c9 [file] [log] [blame]
# Copyright 2014 The Chromium OS Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""A factory test that utilizes Chameleon to do automated display testing."""
from __future__ import print_function
import contextlib
import logging
import os
import tempfile
import time
import unittest
import xmlrpclib
from PIL import Image
from PIL import ImageChops
from PIL import ImageDraw
import factory_common # pylint: disable=unused-import
from cros.factory.device import device_utils
from cros.factory.test.i18n import test_ui as i18n_test_ui
from cros.factory.test import state
from cros.factory.test import test_ui
from cros.factory.test import ui_templates
from cros.factory.utils import arg_utils
from cros.factory.utils import sync_utils
from cros.factory.utils import type_utils
PORTS = type_utils.Enum(['DP', 'HDMI'])
EDIDS = {
PORTS.DP: {
('2560x1600', '60Hz'): 'DP_2560x1600_60Hz',
('1920x1080', '60Hz'): 'DP_1920x1080_60Hz',
},
PORTS.HDMI: {
('3840x2160', '30Hz'): 'HDMI_3840x2160_30Hz',
('1920x1200', '60Hz'): 'HDMI_1920x1200_60Hz',
('1920x1080', '60Hz'): 'HDMI_1920x1080_60Hz',
}
}
DEFAULT_CSS = 'body { font-size: 32px; }'
class Chameleon(object):
"""An interface to the Chameleon RPC server.
Properties:
chameleond: The XMLRPC server proxy for the Chameleond on the Chameleon
board.
"""
PORT_ID_MAP = {
PORTS.DP: 1,
PORTS.HDMI: 3,
}
def __init__(self, hostname, port):
self.chameleond = xmlrpclib.ServerProxy('http://%s:%s' % (hostname, port))
def Reset(self):
"""Resets the Chameleon board."""
self.chameleond.Reset()
def IsPhysicallyPlugged(self, port):
"""Checks if the give port is physically plugged.
Args:
port: The port to check.
"""
return self.chameleond.IsPhysicalPlugged(self.PORT_ID_MAP[port])
def Plug(self, port):
"""Plugs the given port.
Args:
port: The port to plug.
"""
logging.info('Emit HPD on %s port', port)
self.chameleond.Plug(self.PORT_ID_MAP[port])
def CreateEdid(self, edid):
"""Creates a EDID instance on the Chameleon board.
Args:
edid: A byte string of the EDID.
Returns:
The ID of the created EDID instance.
"""
return self.chameleond.CreateEdid(xmlrpclib.Binary(edid))
def ApplyEdid(self, port, edid_id):
"""Applies the given EDID on the port.
Args:
port: The port.
edid_id: The EDID ID.
"""
self.chameleond.ApplyEdid(self.PORT_ID_MAP[port], edid_id)
def DumpPixels(self, port):
"""Dumps the pixels on the given port.
Args:
port: The port to dump.
Returns:
A byte string of the dumped RGB pixels.
"""
return self.chameleond.DumpPixels(self.PORT_ID_MAP[port]).data
def DestroyEdid(self, edid_id):
"""Destroys the give EDID instance.
Args:
edid_id: The ID of the EDID instance.
"""
self.chameleond.DestroyEdid(edid_id)
def GetResolution(self, port):
"""Gets the active resolution of the give port.
Args:
port: The port.
Returns:
A (width, height) tuple representing the resolution.
"""
resolution = self.chameleond.DetectResolution(self.PORT_ID_MAP[port])
logging.info('Chameleon %s port resolution: %s', port, resolution)
return resolution
def Capture(self, port):
"""Captures the framebuffer on the give port.
Args:
port: The port to capture.
Returns:
A PIL.Image object of the captured RGB image.
"""
return Image.fromstring(
'RGB', self.GetResolution(port), self.DumpPixels(port))
@contextlib.contextmanager
def PortEdid(self, port, edid):
"""A context manager to run the given EDID of the given port.
Args:
port: The port.
edid: The EDID byte string.
Yields:
The ID of the created EDID instance.
"""
edid_id = self.CreateEdid(edid)
self.ApplyEdid(port, edid_id)
try:
yield edid_id
finally:
self.DestroyEdid(edid_id)
class ChameleonDisplayTest(unittest.TestCase):
"""A factory test that utilizes Chameleon to do automated display testing."""
ARGS = [
arg_utils.Arg('chameleon_host', str,
'the hostname/IP address of the Chameleon server'),
arg_utils.Arg('chameleon_port', int,
'the port of the Chameleon server', default=9992),
arg_utils.Arg('test_info', tuple,
('a tuple of (dut_port, chameleon_port, resolution_width, '
'resolution_height, refresh_rate); for example: '
'("DP1", "DP", 1920, 1080, 60) or '
'("DP1", "HDMI", 1920, 1080, 60)')),
arg_utils.Arg('load_test_image', bool,
('whether to load the reference pattern image; True to '
'load the test image in a Chrome window on the external '
'display, which may have timing issue to the test caused '
"by Chrome's pop-up messages"), default=False),
arg_utils.Arg('ignore_regions', list,
('a list of regions to ignore when comparing captured '
'images; each element of the list must be a (x, y, width, '
'height) tuple to specify the rectangle to ignore'),
default=[]),
arg_utils.Arg('downscale_to_tv_level', bool,
('whether to downscale the internal framebuffer to TV '
'level for comparison'), default=False),
]
IMAGE_TEMPLATE_WIDTH = 1680
IMAGE_TEMPLATE_HEIGHT = 988
IMAGE_TEMPLATE_FILENAME = 'template-%sx%s.svg' % (
IMAGE_TEMPLATE_WIDTH, IMAGE_TEMPLATE_HEIGHT)
CHAMELEON_IMAGE_PATH = '/usr/local/chameleon.png'
INTERNAL_IMAGE_PATH = '/usr/local/internal.png'
DIFF_IMAGE_PATH = '/usr/local/diff_image.png'
UI_IMAGE_RESIZE_RATIO = 0.4
def setUp(self):
self.dut = device_utils.CreateDUTInterface()
self.ui = test_ui.UI(css=DEFAULT_CSS)
self.ui_template = ui_templates.OneSection(self.ui)
self.ui_template.SetTitle(
i18n_test_ui.MakeI18nLabel('Automated External Display Test'))
self.chameleon = Chameleon(
self.args.chameleon_host, self.args.chameleon_port)
self.goofy_rpc = state.get_instance()
fd, self.image_template_file = tempfile.mkstemp(prefix='image_template.')
os.close(fd)
def tearDown(self):
os.unlink(self.image_template_file)
def ProbeDisplay(self, chameleon_port):
"""Probes the internal/original and the external displays on the given port.
Args:
chameleon_port: The chameleon port to probe.
Returns:
A tuple (original_display, external_display) of the display info of the
probed internal/original and external display.
"""
logging.info('Probing external display...')
def DoProbe():
"""Probes the display info.
Returns:
A tuple (original_display, external_display) of the display info of the
probed internal/original and external display, or None if probing
failed.
"""
display_info = self.goofy_rpc.DeviceGetDisplayInfo()
original_display = None
for info in display_info:
if info['isInternal']:
original_display = info
break
else:
return None
for info in display_info:
if info['id'] != original_display['id'] and not info['isInternal']:
return original_display, info
return None
display_info = self.goofy_rpc.DeviceGetDisplayInfo()
ext_display = None
if len(display_info) == 2:
# pylint: disable=unpacking-non-sequence
orig_display, ext_display = DoProbe()
if not ext_display:
# In case where these is no internal display (e.g. Chromebox), we cannot
# decide which external display is used for testing.
logging.error('Unable to determine the external display to test.')
self.fail('Please unplug the display to test.')
elif len(display_info) == 1:
self.ui_template.SetState(
i18n_test_ui.MakeI18nLabel('Please plug in the display to test'))
logging.info('Checking %s physical port on Chameleon...', chameleon_port)
sync_utils.WaitFor(
lambda: self.chameleon.IsPhysicallyPlugged(chameleon_port),
10, poll_interval=0.5)
logging.info('%s port on Chameleon is physically plugged.',
chameleon_port)
self.chameleon.Plug(chameleon_port)
sync_utils.WaitFor(lambda: DoProbe() is not None, 10, poll_interval=0.5)
# pylint: disable=unpacking-non-sequence
orig_display, ext_display = DoProbe()
else:
self.fail('More than two displays detected; '
'please remove all external displays')
logging.info('External display probed: %s', ext_display)
return (orig_display, ext_display)
@contextlib.contextmanager
def NewWindow(self, left, top, width=None, height=None):
"""Context manager to create a new window with the given attributes.
If width and height are not given, the window is fullscreen by default.
Args:
left: The offset from the left in pixels.
top: The offset from the top in pixels.
width: The width of the new window in pixels.
height: The height of the new window in pixels.
Yields:
The ID of the created window.
"""
logging.info('Creating new window of size %sx%s at +%s+%s...',
width, height, left, top)
window_id = self.goofy_rpc.DeviceCreateWindow(left, top)['id']
if width is not None and height is not None:
self.goofy_rpc.DeviceUpdateWindow(
window_id, {'width': width, 'height': height})
else:
self.goofy_rpc.DeviceUpdateWindow(window_id, {'state': 'fullscreen'})
try:
yield window_id
finally:
self.goofy_rpc.DeviceRemoveWindow(window_id)
def LoadTestImage(self, window_id, width, height):
"""Loads a test image of the given width and height on the given window.
Args:
window_id: The ID of the window.
width: The width of the test image in pixels.
height: The height of the test image in pixels.
"""
logging.info('Loading test image of size %sx%s...', width, height)
image_template = os.path.join(
self.ui.GetStaticDirectoryPath(), self.IMAGE_TEMPLATE_FILENAME)
with open(self.image_template_file, 'w') as output:
with open(image_template) as f:
output.write(f.read().format(
scale_width=float(width) / self.IMAGE_TEMPLATE_WIDTH,
scale_height=float(height) / self.IMAGE_TEMPLATE_HEIGHT))
tab_id = self.goofy_rpc.DeviceQueryTabs(window_id)[0]['id']
url = 'http://127.0.0.1:%s%s' % (
state.DEFAULT_FACTORY_STATE_PORT,
self.ui.URLForFile(self.image_template_file))
self.goofy_rpc.DeviceUpdateTab(tab_id, {'url': url})
def CaptureImages(self, dut_port, chameleon_port):
"""Captures the framebuffers on the given port to RGB images.
This captures both the Chameleon and the internal framebuffers.
Args:
dut_port: The DUT port to capture.
chameleon_port: The Chameleon port to capture.
Returns:
A (chameleon_image, internal_image) tuple of the captured RGB PIL.Image
instances.
"""
logging.info('Capturing %s port framebuffer on Chameleon...',
chameleon_port)
chameleon_image = self.chameleon.Capture(chameleon_port)
logging.info('Capturing %s port framebuffer on DUT...', dut_port)
internal_image = self.dut.display.CaptureFramebuffer(
dut_port, downscale=self.args.downscale_to_tv_level)
return internal_image, chameleon_image
def TestPort(self, dut_port, chameleon_port, width, height, refresh_rate):
"""Tests the given port using the given resolution.
Args:
dut_port: The DUT port to test.
chameleon_port: The Chameleon port to test.
width: The width of the resolution in pixels.
height: The height of the resolution in pixels.
refresh_rate: The screen refresh rate.
"""
mode = ('%sx%s' % (width, height), '%sHz' % refresh_rate)
logging.info(
('Testing DUT %s port on Chameleon %s port using mode %s...'),
dut_port, chameleon_port, mode)
self.ui_template.SetState(i18n_test_ui.MakeI18nLabel(
'Testing DUT {dut_port} port on Chameleon {chameleon_port} port'
' using mode {mode}...',
dut_port=dut_port,
chameleon_port=chameleon_port,
mode=mode))
if not mode in EDIDS[chameleon_port]:
self.fail('Invalid mode for %s: %s' % (chameleon_port, mode))
with open(os.path.join(
self.ui.GetStaticDirectoryPath(), EDIDS[chameleon_port][mode])) as f:
edid = f.read()
with self.chameleon.PortEdid(chameleon_port, edid):
original_display, external_display = self.ProbeDisplay(chameleon_port)
self.ui_template.SetState(i18n_test_ui.MakeI18nLabel(
'Automated testing on {dut_port} to {chameleon_port} in progress...',
dut_port=dut_port,
chameleon_port=chameleon_port))
if self.args.load_test_image:
with self.NewWindow(
external_display['workArea']['left'],
external_display['workArea']['top']) as window_id:
self.LoadTestImage(window_id, width, height)
internal_image, chameleon_image = self.CaptureImages(
dut_port, chameleon_port)
else:
internal_image, chameleon_image = self.CaptureImages(
dut_port, chameleon_port)
logging.info('Comparing captured images...')
diff_image = ImageChops.difference(chameleon_image, internal_image)
chameleon_image.save(self.CHAMELEON_IMAGE_PATH)
internal_image.save(self.INTERNAL_IMAGE_PATH)
logging.info('Cutting off ignored regions...')
for r in self.args.ignore_regions:
x, y, w, h = r
draw = ImageDraw.Draw(diff_image)
draw.rectangle((x, y, x + w, y + h), fill='rgb(0, 0, 0)')
del draw
diff_image.save(self.DIFF_IMAGE_PATH)
histogram = diff_image.convert('L').histogram()
pixel_diff_margin = 1 if self.args.downscale_to_tv_level else 0
if sum(histogram[pixel_diff_margin + 1:]) > 0:
self.ui_template.SetState(
i18n_test_ui.MakeI18nLabel('Captured images mismatch') +
'<br><br>' +
'<image src="%s" width=%d height=%d></image>' %
(self.ui.URLForFile(self.DIFF_IMAGE_PATH),
original_display['workArea']['width'] * self.UI_IMAGE_RESIZE_RATIO,
original_display['workArea']['height'] * self.UI_IMAGE_RESIZE_RATIO))
# Wait 10 seconds for the operator to inspect the difference.
time.sleep(10)
self.fail(('Captured image of port %s from Chameleon does not match '
'the internal framebuffer; check %s for the difference') %
(chameleon_port, self.DIFF_IMAGE_PATH))
def runTest(self):
self.ui.Run(blocking=False)
dut_port, chameleon_port, width, height, refresh_rate = self.args.test_info
self.assertTrue(
chameleon_port in PORTS,
'Invalid port: %s; chameleon port must be one of %s' %
(chameleon_port, PORTS))
# Wait for 5 seconds for the fade-in visual effect.
time.sleep(5)
self.TestPort(dut_port, chameleon_port, width, height, refresh_rate)