OLD | NEW |
1 # Copyright 2017 The Chromium Authors. All rights reserved. | 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 | 2 # Use of this source code is governed by a BSD-style license that can be |
3 # found in the LICENSE file. | 3 # found in the LICENSE file. |
4 | 4 |
5 """Script for automatically measuring motion-to-photon latency for VR. | 5 """Script for automatically measuring motion-to-photon latency for VR. |
6 | 6 |
7 Doing so requires two specialized pieces of hardware. The first is a Motopho, | 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 | 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 | 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 | 10 a set of servos, which physically moves the test device and Motopho during the |
11 latency test. | 11 latency test. |
12 """ | 12 """ |
13 | 13 |
| 14 import android_webvr_latency_test |
| 15 |
14 import argparse | 16 import argparse |
15 import glob | |
16 import httplib | |
17 import logging | 17 import logging |
18 import os | 18 import os |
19 import re | |
20 import serial | |
21 import subprocess | |
22 import sys | 19 import sys |
23 import threading | |
24 import time | |
25 | 20 |
26 # RobotArm connection constants | 21 # TODO(bsheedy): See about having versioned copies of the flicker app |
27 BAUD_RATE = 115200 | 22 # instead of using personal github. |
28 CONNECTION_TIMEOUT = 3.0 | 23 DEFAULT_FLICKER_APP_URL = ('https://weableandbob.github.io/Motopho/' |
29 NUM_TRIES = 5 | 24 'flicker_apps/webvr/webvr-flicker-app-klaus.html?' |
30 # Motopho constants | 25 'polyfill=0\&canvasClickPresents=1') |
31 DEFAULT_ADB_PATH = os.path.join(os.path.expanduser('~'), | 26 DEFAULT_ADB_PATH = os.path.realpath('../../third_party/android_tools/sdk/' |
32 'tools/android/android-sdk-linux', | 27 'platform-tools/adb') |
33 'platform-tools/adb') | |
34 # TODO(bsheedy): See about adding tool via DEPS instead of relying on it | 28 # TODO(bsheedy): See about adding tool via DEPS instead of relying on it |
35 # existing on the bot already | 29 # existing on the bot already |
36 DEFAULT_MOTOPHO_PATH = os.path.join(os.path.expanduser('~'), 'motopho/Motopho') | 30 DEFAULT_MOTOPHO_PATH = os.path.join(os.path.expanduser('~'), 'motopho/Motopho') |
37 MOTOPHO_THREAD_TIMEOUT = 30 | 31 DEFAULT_NUM_SAMPLES = 10 |
38 | 32 DEFAULT_RESULTS_FILE = 'results-chart.json' |
39 class MotophoThread(threading.Thread): | 33 DEFAULT_VRCORE_VERSION_FILE = 'vrcore_version.txt' |
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 | 34 |
107 | 35 |
108 def GetParsedArgs(): | 36 def GetParsedArgs(): |
109 """Parses the command line arguments passed to the script. | 37 """Parses the command line arguments passed to the script. |
110 | 38 |
111 Fails if any unknown arguments are present. | 39 Fails if any unknown arguments are present. |
| 40 |
| 41 Returns: |
| 42 An object containing all known, parsed arguments. |
112 """ | 43 """ |
113 parser = argparse.ArgumentParser() | 44 parser = argparse.ArgumentParser() |
114 parser.add_argument('--adb-path', | 45 parser.add_argument('--adb-path', |
115 type=os.path.realpath, | 46 type=os.path.realpath, |
116 help='The absolute path to adb', | 47 help='The absolute path to adb', |
117 default=DEFAULT_ADB_PATH) | 48 default=DEFAULT_ADB_PATH) |
118 parser.add_argument('--motopho-path', | 49 parser.add_argument('--motopho-path', |
119 type=os.path.realpath, | 50 type=os.path.realpath, |
120 help='The absolute path to the directory with Motopho ' | 51 help='The absolute path to the directory with Motopho ' |
121 'scripts', | 52 'scripts', |
122 default=DEFAULT_MOTOPHO_PATH) | 53 default=DEFAULT_MOTOPHO_PATH) |
123 parser.add_argument('--output-dir', | 54 parser.add_argument('--output-dir', |
124 type=os.path.realpath, | 55 type=os.path.realpath, |
125 help='The directory where the script\'s output files ' | 56 help='The directory where the script\'s output files ' |
126 'will be saved') | 57 'will be saved') |
| 58 parser.add_argument('--platform', |
| 59 help='The platform the test is being run on, either ' |
| 60 '"android" or "windows"') |
| 61 parser.add_argument('--results-file', |
| 62 default=DEFAULT_RESULTS_FILE, |
| 63 help='The name of the JSON file the results will be ' |
| 64 'saved to') |
| 65 parser.add_argument('--num-samples', |
| 66 default=DEFAULT_NUM_SAMPLES, |
| 67 help='The number of times to run the test before ' |
| 68 'the results are averaged') |
| 69 parser.add_argument('--url', |
| 70 default=DEFAULT_FLICKER_APP_URL, |
| 71 help='The URL of the flicker app to use') |
127 parser.add_argument('-v', '--verbose', | 72 parser.add_argument('-v', '--verbose', |
128 dest='verbose_count', default=0, action='count', | 73 dest='verbose_count', default=0, action='count', |
129 help='Verbose level (multiple times for more)') | 74 help='Verbose level (multiple times for more)') |
| 75 parser.add_argument('--vrcore-version-file', |
| 76 default=DEFAULT_VRCORE_VERSION_FILE, |
| 77 help='The name of the text file that the VrCore APK ' |
| 78 'version number will be saved to') |
130 (args, unknown_args) = parser.parse_known_args() | 79 (args, unknown_args) = parser.parse_known_args() |
131 SetLogLevel(args.verbose_count) | 80 SetLogLevel(args.verbose_count) |
132 if unknown_args: | 81 if unknown_args: |
133 parser.error('Received unknown arguments: %s' % ' '.join(unknown_args)) | 82 parser.error('Received unknown arguments: %s' % ' '.join(unknown_args)) |
134 return args | 83 return args |
135 | 84 |
136 | 85 |
137 def SetLogLevel(verbose_count): | 86 def SetLogLevel(verbose_count): |
138 """Sets the log level based on the command line arguments.""" | 87 """Sets the log level based on the command line arguments.""" |
139 log_level = logging.WARNING | 88 log_level = logging.WARNING |
140 if verbose_count == 1: | 89 if verbose_count == 1: |
141 log_level = logging.INFO | 90 log_level = logging.INFO |
142 elif verbose_count >= 2: | 91 elif verbose_count >= 2: |
143 log_level = logging.DEBUG | 92 log_level = logging.DEBUG |
144 logger = logging.getLogger() | 93 logger = logging.getLogger() |
145 logger.setLevel(log_level) | 94 logger.setLevel(log_level) |
146 | 95 |
147 | 96 |
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(): | 97 def main(): |
208 args = GetParsedArgs() | 98 args = GetParsedArgs() |
| 99 latency_test = None |
| 100 if args.platform == 'android': |
| 101 latency_test = android_webvr_latency_test.AndroidWebVrLatencyTest(args) |
| 102 elif args.platform == 'win': |
| 103 raise NotImplementedError('WebVR not currently supported on Windows') |
| 104 else: |
| 105 raise RuntimeError('Given platform %s not recognized' % args.platform) |
| 106 latency_test.RunTest() |
209 | 107 |
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 | 108 |
282 if __name__ == '__main__': | 109 if __name__ == '__main__': |
283 sys.exit(main()) | 110 sys.exit(main()) |
OLD | NEW |