| Index: chrome/test/vr/perf/latency/run_latency_test.py
|
| diff --git a/chrome/test/vr/perf/latency/run_latency_test.py b/chrome/test/vr/perf/latency/run_latency_test.py
|
| new file mode 100644
|
| index 0000000000000000000000000000000000000000..852b42045eb2341c4e503090d2ea004457bfe2f5
|
| --- /dev/null
|
| +++ b/chrome/test/vr/perf/latency/run_latency_test.py
|
| @@ -0,0 +1,283 @@
|
| +# Copyright 2017 The Chromium Authors. All rights reserved.
|
| +# Use of this source code is governed by a BSD-style license that can be
|
| +# found in the LICENSE file.
|
| +
|
| +"""Script for automatically measuring motion-to-photon latency for VR.
|
| +
|
| +Doing so requires two specialized pieces of hardware. The first is a Motopho,
|
| +which when used with a VR flicker app, finds the delay between movement and
|
| +the test device's screen updating in response to the movement. The second is
|
| +a set of servos, which physically moves the test device and Motopho during the
|
| +latency test.
|
| +"""
|
| +
|
| +import argparse
|
| +import glob
|
| +import httplib
|
| +import logging
|
| +import os
|
| +import re
|
| +import serial
|
| +import subprocess
|
| +import sys
|
| +import threading
|
| +import time
|
| +
|
| +# RobotArm connection constants
|
| +BAUD_RATE = 115200
|
| +CONNECTION_TIMEOUT = 3.0
|
| +NUM_TRIES = 5
|
| +# Motopho constants
|
| +DEFAULT_ADB_PATH = os.path.join(os.path.expanduser('~'),
|
| + 'tools/android/android-sdk-linux',
|
| + 'platform-tools/adb')
|
| +# TODO(bsheedy): See about adding tool via DEPS instead of relying on it
|
| +# existing on the bot already
|
| +DEFAULT_MOTOPHO_PATH = os.path.join(os.path.expanduser('~'), 'motopho/Motopho')
|
| +MOTOPHO_THREAD_TIMEOUT = 30
|
| +
|
| +class MotophoThread(threading.Thread):
|
| + """Handles the running of the Motopho script and extracting results."""
|
| + def __init__(self):
|
| + threading.Thread.__init__(self)
|
| + self._latency = None
|
| + self._max_correlation = None
|
| +
|
| + def run(self):
|
| + motopho_output = ""
|
| + try:
|
| + motopho_output = subprocess.check_output(["./motophopro_nograph"],
|
| + stderr=subprocess.STDOUT)
|
| + except subprocess.CalledProcessError as e:
|
| + logging.error('Failed to run Motopho script: %s', e.output)
|
| + raise e
|
| +
|
| + if "FAIL" in motopho_output:
|
| + logging.error('Failed to get latency, logging raw output: %s',
|
| + motopho_output)
|
| + raise RuntimeError('Failed to get latency - correlation likely too low')
|
| +
|
| + self._latency = None
|
| + self._max_correlation = None
|
| + for line in motopho_output.split("\n"):
|
| + if 'Motion-to-photon latency:' in line:
|
| + self._latency = float(line.split(" ")[-2])
|
| + if 'Max correlation is' in line:
|
| + self._max_correlation = float(line.split(' ')[-1])
|
| + if self._latency and self._max_correlation:
|
| + break;
|
| +
|
| + @property
|
| + def latency(self):
|
| + return self._latency
|
| +
|
| + @property
|
| + def max_correlation(self):
|
| + return self._max_correlation
|
| +
|
| +
|
| +class RobotArm():
|
| + """Handles the serial communication with the servos/arm used for movement."""
|
| + def __init__(self, device_name, num_tries, baud, timeout):
|
| + self._connection = None
|
| + connected = False
|
| + for _ in xrange(num_tries):
|
| + try:
|
| + self._connection = serial.Serial('/dev/' + device_name,
|
| + baud,
|
| + timeout=timeout)
|
| + except serial.SerialException as e:
|
| + pass
|
| + if self._connection and 'Enter parameters' in self._connection.read(1024):
|
| + connected = True
|
| + break
|
| + if not connected:
|
| + raise serial.SerialException('Failed to connect to the robot arm.')
|
| +
|
| + def StartMotophoMovement(self):
|
| + if not self._connection:
|
| + return
|
| + self._connection.write('9\n')
|
| +
|
| + def StopAllMovement(self):
|
| + if not self._connection:
|
| + return
|
| + self._connection.write('0\n')
|
| +
|
| +
|
| +def GetParsedArgs():
|
| + """Parses the command line arguments passed to the script.
|
| +
|
| + Fails if any unknown arguments are present.
|
| + """
|
| + parser = argparse.ArgumentParser()
|
| + parser.add_argument('--adb-path',
|
| + type=os.path.realpath,
|
| + help='The absolute path to adb',
|
| + default=DEFAULT_ADB_PATH)
|
| + parser.add_argument('--motopho-path',
|
| + type=os.path.realpath,
|
| + help='The absolute path to the directory with Motopho '
|
| + 'scripts',
|
| + default=DEFAULT_MOTOPHO_PATH)
|
| + parser.add_argument('--output-dir',
|
| + type=os.path.realpath,
|
| + help='The directory where the script\'s output files '
|
| + 'will be saved')
|
| + parser.add_argument('-v', '--verbose',
|
| + dest='verbose_count', default=0, action='count',
|
| + help='Verbose level (multiple times for more)')
|
| + (args, unknown_args) = parser.parse_known_args()
|
| + SetLogLevel(args.verbose_count)
|
| + if unknown_args:
|
| + parser.error('Received unknown arguments: %s' % ' '.join(unknown_args))
|
| + return args
|
| +
|
| +
|
| +def SetLogLevel(verbose_count):
|
| + """Sets the log level based on the command line arguments."""
|
| + log_level = logging.WARNING
|
| + if verbose_count == 1:
|
| + log_level = logging.INFO
|
| + elif verbose_count >= 2:
|
| + log_level = logging.DEBUG
|
| + logger = logging.getLogger()
|
| + logger.setLevel(log_level)
|
| +
|
| +
|
| +def GetTtyDevices(tty_pattern, vendor_ids):
|
| + """Find all devices connected to tty that match a pattern and device id.
|
| +
|
| + If a serial device is connected to the computer via USB, this function
|
| + will check all tty devices that match tty_pattern, and return the ones
|
| + that have vendor identification number in the list vendor_ids.
|
| +
|
| + Args:
|
| + tty_pattern: The search pattern, such as r'ttyACM\d+'.
|
| + vendor_ids: The list of 16-bit USB vendor ids, such as [0x2a03].
|
| +
|
| + Returns:
|
| + A list of strings of tty devices, for example ['ttyACM0'].
|
| + """
|
| + product_string = 'PRODUCT='
|
| + sys_class_dir = '/sys/class/tty/'
|
| +
|
| + tty_devices = glob.glob(sys_class_dir + '*')
|
| +
|
| + matcher = re.compile('.*' + tty_pattern)
|
| + tty_matches = [x for x in tty_devices if matcher.search(x)]
|
| + tty_matches = [x[len(sys_class_dir):] for x in tty_matches]
|
| +
|
| + found_devices = []
|
| + for match in tty_matches:
|
| + class_filename = sys_class_dir + match + '/device/uevent'
|
| + with open(class_filename, 'r') as uevent_file:
|
| + # Look for the desired product id in the uevent text.
|
| + for line in uevent_file:
|
| + if product_string in line:
|
| + ids = line[len(product_string):].split('/')
|
| + ids = [int(x, 16) for x in ids]
|
| +
|
| + for desired_id in vendor_ids:
|
| + if desired_id in ids:
|
| + found_devices.append(match)
|
| +
|
| + return found_devices
|
| +
|
| +
|
| +def RunCommand(cmd):
|
| + """Runs the given cmd list.
|
| +
|
| + Prints the command's output and exits if any error occurs.
|
| + """
|
| + try:
|
| + subprocess.check_output(cmd, stderr=subprocess.STDOUT)
|
| + except subprocess.CalledProcessError as e:
|
| + logging.error('Failed command output: %s', e.output)
|
| + raise e
|
| +
|
| +
|
| +def SetChromeCommandLineFlags(adb_path, flags):
|
| + """Sets the given Chrome command line flags."""
|
| + RunCommand([adb_path,
|
| + 'shell', "echo 'chrome " + ' '.join(flags) + "' > "
|
| + + '/data/local/tmp/chrome-command-line'])
|
| +
|
| +
|
| +def main():
|
| + args = GetParsedArgs()
|
| +
|
| + RunCommand([args.adb_path, 'root'])
|
| + RunCommand([args.adb_path, 'install', '-r', 'apks/ChromePublic.apk'])
|
| + # Force WebVR support and don't have first run experience
|
| + SetChromeCommandLineFlags(args.adb_path, ['--enable-webvr', '--disable-fre'])
|
| +
|
| + # Motopho scripts use relative paths, so switch to the Motopho directory
|
| + os.chdir(args.motopho_path)
|
| +
|
| + # Connect to the Arduino that drives the servos
|
| + devices = GetTtyDevices(r'ttyACM\d+', [0x2a03, 0x2341])
|
| + if len(devices) != 1:
|
| + logging.error('Found %d devices, expected 1', len(devices))
|
| + return 1
|
| + robot_arm = RobotArm(devices[0], NUM_TRIES, BAUD_RATE, CONNECTION_TIMEOUT)
|
| +
|
| + # Wake the device
|
| + RunCommand([args.adb_path, 'shell', 'input', 'keyevent', 'KEYCODE_WAKEUP'])
|
| + # Sleep a bit, otherwise WebGL can crash when Canary starts
|
| + time.sleep(1)
|
| +
|
| + # Start Chrome and go to the flicker app
|
| + # TODO(bsheedy): See about having versioned copies of the flicker app instead
|
| + # of using personal github.
|
| + RunCommand([args.adb_path, 'shell', 'am', 'start',
|
| + '-a', 'android.intent.action.MAIN',
|
| + '-n', 'org.chromium.chrome/com.google.android.apps.chrome.Main',
|
| + 'https://weableandbob.github.io/Motopho/flicker_apps/webvr/webvr-flicker-app-klaus.html?polyfill=0\&canvasClickPresents=1'])
|
| + time.sleep(10)
|
| +
|
| + # Tap the screen to start presenting
|
| + RunCommand(
|
| + [args.adb_path, 'shell', 'input', 'touchscreen', 'tap', '800', '800'])
|
| + # Wait for VR to fully start up
|
| + time.sleep(5)
|
| +
|
| + # Start the Motopho script
|
| + motopho_thread = MotophoThread()
|
| + motopho_thread.start()
|
| + # Let the Motopho be stationary so the script can calculate its bias
|
| + time.sleep(3)
|
| +
|
| + # Move so we can measure latency
|
| + robot_arm.StartMotophoMovement()
|
| + motopho_thread.join(MOTOPHO_THREAD_TIMEOUT)
|
| + if motopho_thread.isAlive():
|
| + # TODO(bsheedy): Look into ways to prevent Motopho from not sending any
|
| + # data until unplugged and replugged into the machine after a reboot.
|
| + logging.error('Motopho thread timeout, Motopho may need to be replugged.')
|
| + robot_arm.StopAllMovement()
|
| +
|
| + logging.info('Latency: %s', motopho_thread.latency)
|
| + logging.info('Max correlation: %s', motopho_thread.max_correlation)
|
| +
|
| + # TODO(bsheedy): Change this to output JSON compatible with the performance
|
| + # dashboard.
|
| + if args.output_dir and os.path.isdir(args.output_dir):
|
| + with file(os.path.join(args.output_dir, 'output.txt'), 'w') as outfile:
|
| + outfile.write('Latency: %s\nMax correlation: %s\n' %
|
| + (motopho_thread.latency, motopho_thread.max_correlation))
|
| +
|
| + # Exit VR and Close Chrome
|
| + # TODO(bsheedy): See about closing current tab before exiting so they don't
|
| + # pile up over time.
|
| + RunCommand([args.adb_path, 'shell', 'input', 'keyevent', 'KEYCODE_BACK'])
|
| + RunCommand([args.adb_path, 'shell', 'am', 'force-stop',
|
| + 'org.chromium.chrome'])
|
| +
|
| + # Turn off the screen
|
| + RunCommand([args.adb_path, 'shell', 'input', 'keyevent', 'KEYCODE_POWER'])
|
| +
|
| + return 0
|
| +
|
| +if __name__ == '__main__':
|
| + sys.exit(main())
|
|
|