OLD | NEW |
(Empty) | |
| 1 # Copyright 2017 The Chromium 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 """Script for automatically measuring motion-to-photon latency for VR. |
| 6 |
| 7 Doing so requires two specialized pieces of hardware. The first is a Motopho, |
| 8 which when used with a VR flicker app, finds the delay between movement and |
| 9 the test device's screen updating in response to the movement. The second is |
| 10 a set of servos, which physically moves the test device and Motopho during the |
| 11 latency test. |
| 12 """ |
| 13 |
| 14 import argparse |
| 15 import glob |
| 16 import httplib |
| 17 import logging |
| 18 import os |
| 19 import re |
| 20 import serial |
| 21 import subprocess |
| 22 import sys |
| 23 import threading |
| 24 import time |
| 25 |
| 26 # RobotArm connection constants |
| 27 BAUD_RATE = 115200 |
| 28 CONNECTION_TIMEOUT = 3.0 |
| 29 NUM_TRIES = 5 |
| 30 # Motopho constants |
| 31 DEFAULT_ADB_PATH = os.path.join(os.path.expanduser('~'), |
| 32 'tools/android/android-sdk-linux', |
| 33 'platform-tools/adb') |
| 34 # TODO(bsheedy): See about adding tool via DEPS instead of relying on it |
| 35 # existing on the bot already |
| 36 DEFAULT_MOTOPHO_PATH = os.path.join(os.path.expanduser('~'), 'motopho/Motopho') |
| 37 MOTOPHO_THREAD_TIMEOUT = 30 |
| 38 |
| 39 class MotophoThread(threading.Thread): |
| 40 """Handles the running of the Motopho script and extracting results.""" |
| 41 def __init__(self): |
| 42 threading.Thread.__init__(self) |
| 43 self._latency = None |
| 44 self._max_correlation = None |
| 45 |
| 46 def run(self): |
| 47 motopho_output = "" |
| 48 try: |
| 49 motopho_output = subprocess.check_output(["./motophopro_nograph"], |
| 50 stderr=subprocess.STDOUT) |
| 51 except subprocess.CalledProcessError as e: |
| 52 logging.error('Failed to run Motopho script: %s', e.output) |
| 53 raise e |
| 54 |
| 55 if "FAIL" in motopho_output: |
| 56 logging.error('Failed to get latency, logging raw output: %s', |
| 57 motopho_output) |
| 58 raise RuntimeError('Failed to get latency - correlation likely too low') |
| 59 |
| 60 self._latency = None |
| 61 self._max_correlation = None |
| 62 for line in motopho_output.split("\n"): |
| 63 if 'Motion-to-photon latency:' in line: |
| 64 self._latency = float(line.split(" ")[-2]) |
| 65 if 'Max correlation is' in line: |
| 66 self._max_correlation = float(line.split(' ')[-1]) |
| 67 if self._latency and self._max_correlation: |
| 68 break; |
| 69 |
| 70 @property |
| 71 def latency(self): |
| 72 return self._latency |
| 73 |
| 74 @property |
| 75 def max_correlation(self): |
| 76 return self._max_correlation |
| 77 |
| 78 |
| 79 class RobotArm(): |
| 80 """Handles the serial communication with the servos/arm used for movement.""" |
| 81 def __init__(self, device_name, num_tries, baud, timeout): |
| 82 self._connection = None |
| 83 connected = False |
| 84 for _ in xrange(num_tries): |
| 85 try: |
| 86 self._connection = serial.Serial('/dev/' + device_name, |
| 87 baud, |
| 88 timeout=timeout) |
| 89 except serial.SerialException as e: |
| 90 pass |
| 91 if self._connection and 'Enter parameters' in self._connection.read(1024): |
| 92 connected = True |
| 93 break |
| 94 if not connected: |
| 95 raise serial.SerialException('Failed to connect to the robot arm.') |
| 96 |
| 97 def StartMotophoMovement(self): |
| 98 if not self._connection: |
| 99 return |
| 100 self._connection.write('9\n') |
| 101 |
| 102 def StopAllMovement(self): |
| 103 if not self._connection: |
| 104 return |
| 105 self._connection.write('0\n') |
| 106 |
| 107 |
| 108 def GetParsedArgs(): |
| 109 """Parses the command line arguments passed to the script. |
| 110 |
| 111 Fails if any unknown arguments are present. |
| 112 """ |
| 113 parser = argparse.ArgumentParser() |
| 114 parser.add_argument('--adb-path', |
| 115 type=os.path.realpath, |
| 116 help='The absolute path to adb', |
| 117 default=DEFAULT_ADB_PATH) |
| 118 parser.add_argument('--motopho-path', |
| 119 type=os.path.realpath, |
| 120 help='The absolute path to the directory with Motopho ' |
| 121 'scripts', |
| 122 default=DEFAULT_MOTOPHO_PATH) |
| 123 parser.add_argument('--output-dir', |
| 124 type=os.path.realpath, |
| 125 help='The directory where the script\'s output files ' |
| 126 'will be saved') |
| 127 parser.add_argument('-v', '--verbose', |
| 128 dest='verbose_count', default=0, action='count', |
| 129 help='Verbose level (multiple times for more)') |
| 130 (args, unknown_args) = parser.parse_known_args() |
| 131 SetLogLevel(args.verbose_count) |
| 132 if unknown_args: |
| 133 parser.error('Received unknown arguments: %s' % ' '.join(unknown_args)) |
| 134 return args |
| 135 |
| 136 |
| 137 def SetLogLevel(verbose_count): |
| 138 """Sets the log level based on the command line arguments.""" |
| 139 log_level = logging.WARNING |
| 140 if verbose_count == 1: |
| 141 log_level = logging.INFO |
| 142 elif verbose_count >= 2: |
| 143 log_level = logging.DEBUG |
| 144 logger = logging.getLogger() |
| 145 logger.setLevel(log_level) |
| 146 |
| 147 |
| 148 def GetTtyDevices(tty_pattern, vendor_ids): |
| 149 """Find all devices connected to tty that match a pattern and device id. |
| 150 |
| 151 If a serial device is connected to the computer via USB, this function |
| 152 will check all tty devices that match tty_pattern, and return the ones |
| 153 that have vendor identification number in the list vendor_ids. |
| 154 |
| 155 Args: |
| 156 tty_pattern: The search pattern, such as r'ttyACM\d+'. |
| 157 vendor_ids: The list of 16-bit USB vendor ids, such as [0x2a03]. |
| 158 |
| 159 Returns: |
| 160 A list of strings of tty devices, for example ['ttyACM0']. |
| 161 """ |
| 162 product_string = 'PRODUCT=' |
| 163 sys_class_dir = '/sys/class/tty/' |
| 164 |
| 165 tty_devices = glob.glob(sys_class_dir + '*') |
| 166 |
| 167 matcher = re.compile('.*' + tty_pattern) |
| 168 tty_matches = [x for x in tty_devices if matcher.search(x)] |
| 169 tty_matches = [x[len(sys_class_dir):] for x in tty_matches] |
| 170 |
| 171 found_devices = [] |
| 172 for match in tty_matches: |
| 173 class_filename = sys_class_dir + match + '/device/uevent' |
| 174 with open(class_filename, 'r') as uevent_file: |
| 175 # Look for the desired product id in the uevent text. |
| 176 for line in uevent_file: |
| 177 if product_string in line: |
| 178 ids = line[len(product_string):].split('/') |
| 179 ids = [int(x, 16) for x in ids] |
| 180 |
| 181 for desired_id in vendor_ids: |
| 182 if desired_id in ids: |
| 183 found_devices.append(match) |
| 184 |
| 185 return found_devices |
| 186 |
| 187 |
| 188 def RunCommand(cmd): |
| 189 """Runs the given cmd list. |
| 190 |
| 191 Prints the command's output and exits if any error occurs. |
| 192 """ |
| 193 try: |
| 194 subprocess.check_output(cmd, stderr=subprocess.STDOUT) |
| 195 except subprocess.CalledProcessError as e: |
| 196 logging.error('Failed command output: %s', e.output) |
| 197 raise e |
| 198 |
| 199 |
| 200 def SetChromeCommandLineFlags(adb_path, flags): |
| 201 """Sets the given Chrome command line flags.""" |
| 202 RunCommand([adb_path, |
| 203 'shell', "echo 'chrome " + ' '.join(flags) + "' > " |
| 204 + '/data/local/tmp/chrome-command-line']) |
| 205 |
| 206 |
| 207 def main(): |
| 208 args = GetParsedArgs() |
| 209 |
| 210 RunCommand([args.adb_path, 'root']) |
| 211 RunCommand([args.adb_path, 'install', '-r', 'apks/ChromePublic.apk']) |
| 212 # Force WebVR support and don't have first run experience |
| 213 SetChromeCommandLineFlags(args.adb_path, ['--enable-webvr', '--disable-fre']) |
| 214 |
| 215 # Motopho scripts use relative paths, so switch to the Motopho directory |
| 216 os.chdir(args.motopho_path) |
| 217 |
| 218 # Connect to the Arduino that drives the servos |
| 219 devices = GetTtyDevices(r'ttyACM\d+', [0x2a03, 0x2341]) |
| 220 if len(devices) != 1: |
| 221 logging.error('Found %d devices, expected 1', len(devices)) |
| 222 return 1 |
| 223 robot_arm = RobotArm(devices[0], NUM_TRIES, BAUD_RATE, CONNECTION_TIMEOUT) |
| 224 |
| 225 # Wake the device |
| 226 RunCommand([args.adb_path, 'shell', 'input', 'keyevent', 'KEYCODE_WAKEUP']) |
| 227 # Sleep a bit, otherwise WebGL can crash when Canary starts |
| 228 time.sleep(1) |
| 229 |
| 230 # Start Chrome and go to the flicker app |
| 231 # TODO(bsheedy): See about having versioned copies of the flicker app instead |
| 232 # of using personal github. |
| 233 RunCommand([args.adb_path, 'shell', 'am', 'start', |
| 234 '-a', 'android.intent.action.MAIN', |
| 235 '-n', 'org.chromium.chrome/com.google.android.apps.chrome.Main', |
| 236 'https://weableandbob.github.io/Motopho/flicker_apps/webvr/webvr-
flicker-app-klaus.html?polyfill=0\&canvasClickPresents=1']) |
| 237 time.sleep(10) |
| 238 |
| 239 # Tap the screen to start presenting |
| 240 RunCommand( |
| 241 [args.adb_path, 'shell', 'input', 'touchscreen', 'tap', '800', '800']) |
| 242 # Wait for VR to fully start up |
| 243 time.sleep(5) |
| 244 |
| 245 # Start the Motopho script |
| 246 motopho_thread = MotophoThread() |
| 247 motopho_thread.start() |
| 248 # Let the Motopho be stationary so the script can calculate its bias |
| 249 time.sleep(3) |
| 250 |
| 251 # Move so we can measure latency |
| 252 robot_arm.StartMotophoMovement() |
| 253 motopho_thread.join(MOTOPHO_THREAD_TIMEOUT) |
| 254 if motopho_thread.isAlive(): |
| 255 # TODO(bsheedy): Look into ways to prevent Motopho from not sending any |
| 256 # data until unplugged and replugged into the machine after a reboot. |
| 257 logging.error('Motopho thread timeout, Motopho may need to be replugged.') |
| 258 robot_arm.StopAllMovement() |
| 259 |
| 260 logging.info('Latency: %s', motopho_thread.latency) |
| 261 logging.info('Max correlation: %s', motopho_thread.max_correlation) |
| 262 |
| 263 # TODO(bsheedy): Change this to output JSON compatible with the performance |
| 264 # dashboard. |
| 265 if args.output_dir and os.path.isdir(args.output_dir): |
| 266 with file(os.path.join(args.output_dir, 'output.txt'), 'w') as outfile: |
| 267 outfile.write('Latency: %s\nMax correlation: %s\n' % |
| 268 (motopho_thread.latency, motopho_thread.max_correlation)) |
| 269 |
| 270 # Exit VR and Close Chrome |
| 271 # TODO(bsheedy): See about closing current tab before exiting so they don't |
| 272 # pile up over time. |
| 273 RunCommand([args.adb_path, 'shell', 'input', 'keyevent', 'KEYCODE_BACK']) |
| 274 RunCommand([args.adb_path, 'shell', 'am', 'force-stop', |
| 275 'org.chromium.chrome']) |
| 276 |
| 277 # Turn off the screen |
| 278 RunCommand([args.adb_path, 'shell', 'input', 'keyevent', 'KEYCODE_POWER']) |
| 279 |
| 280 return 0 |
| 281 |
| 282 if __name__ == '__main__': |
| 283 sys.exit(main()) |
OLD | NEW |