1#!/usr/bin/env python3 2# Copyright 2020 The Pigweed Authors 3# 4# Licensed under the Apache License, Version 2.0 (the "License"); you may not 5# use this file except in compliance with the License. You may obtain a copy of 6# the License at 7# 8# https://www.apache.org/licenses/LICENSE-2.0 9# 10# Unless required by applicable law or agreed to in writing, software 11# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13# License for the specific language governing permissions and limitations under 14# the License. 15"""This script flashes and runs unit tests onto Arduino boards.""" 16 17import argparse 18import logging 19import os 20import platform 21import re 22import subprocess 23import sys 24import time 25from pathlib import Path 26 27import serial 28import serial.tools.list_ports 29import pw_arduino_build.log 30from pw_arduino_build import teensy_detector 31from pw_arduino_build.file_operations import decode_file_json 32 33_LOG = logging.getLogger('unit_test_runner') 34 35# Verification of test pass/failure depends on these strings. If the formatting 36# or output of the simple_printing_event_handler changes, this may need to be 37# updated. 38_TESTS_STARTING_STRING = b'[==========] Running all tests.' 39_TESTS_DONE_STRING = b'[==========] Done running all tests.' 40_TEST_FAILURE_STRING = b'[ FAILED ]' 41 42# How long to wait for the first byte of a test to be emitted. This is longer 43# than the user-configurable timeout as there's a delay while the device is 44# flashed. 45_FLASH_TIMEOUT = 5.0 46 47 48class TestingFailure(Exception): 49 """A simple exception to be raised when a testing step fails.""" 50 51 52class DeviceNotFound(Exception): 53 """A simple exception to be raised when unable to connect to a device.""" 54 55 56class ArduinoCoreNotSupported(Exception): 57 """Exception raised when a given core does not support unit testing.""" 58 59 60def valid_file_name(arg): 61 file_path = Path(os.path.expandvars(arg)).absolute() 62 if not file_path.is_file(): 63 raise argparse.ArgumentTypeError(f"'{arg}' does not exist.") 64 return file_path 65 66 67def parse_args(): 68 """Parses command-line arguments.""" 69 70 parser = argparse.ArgumentParser(description=__doc__) 71 parser.add_argument( 72 'binary', help='The target test binary to run', type=valid_file_name 73 ) 74 parser.add_argument( 75 '--port', 76 help='The name of the serial port to connect to when ' 'running tests', 77 ) 78 parser.add_argument( 79 '--baud', 80 type=int, 81 default=115200, 82 help='Target baud rate to use for serial communication' 83 ' with target device', 84 ) 85 parser.add_argument( 86 '--test-timeout', 87 type=float, 88 default=5.0, 89 help='Maximum communication delay in seconds before a ' 90 'test is considered unresponsive and aborted', 91 ) 92 parser.add_argument( 93 '--verbose', 94 '-v', 95 dest='verbose', 96 action='store_true', 97 help='Output additional logs as the script runs', 98 ) 99 100 parser.add_argument( 101 '--flash-only', 102 action='store_true', 103 help="Don't check for test output after flashing.", 104 ) 105 106 # arduino_builder arguments 107 # TODO(tonymd): Get these args from __main__.py or elsewhere. 108 parser.add_argument( 109 "-c", "--config-file", required=True, help="Path to a config file." 110 ) 111 parser.add_argument( 112 "--arduino-package-path", 113 help="Path to the arduino IDE install location.", 114 ) 115 parser.add_argument( 116 "--arduino-package-name", 117 help="Name of the Arduino board package to use.", 118 ) 119 parser.add_argument( 120 "--compiler-path-override", 121 help="Path to arm-none-eabi-gcc bin folder. " 122 "Default: Arduino core specified gcc", 123 ) 124 parser.add_argument("--board", help="Name of the Arduino board to use.") 125 parser.add_argument( 126 "--upload-tool", 127 required=True, 128 help="Name of the Arduino upload tool to use.", 129 ) 130 parser.add_argument( 131 "--set-variable", 132 action="append", 133 metavar='some.variable=NEW_VALUE', 134 help="Override an Arduino recipe variable. May be " 135 "specified multiple times. For example: " 136 "--set-variable 'serial.port.label=/dev/ttyACM0' " 137 "--set-variable 'serial.port.protocol=Teensy'", 138 ) 139 return parser.parse_args() 140 141 142def log_subprocess_output(level, output): 143 """Logs subprocess output line-by-line.""" 144 145 lines = output.decode('utf-8', errors='replace').splitlines() 146 for line in lines: 147 _LOG.log(level, line) 148 149 150def read_serial(port, baud_rate, test_timeout) -> bytes: 151 """Reads lines from a serial port until a line read times out. 152 153 Returns bytes object containing the read serial data. 154 """ 155 156 serial_data = bytearray() 157 device = serial.Serial( 158 baudrate=baud_rate, port=port, timeout=_FLASH_TIMEOUT 159 ) 160 if not device.is_open: 161 raise TestingFailure('Failed to open device') 162 163 # Flush input buffer and reset the device to begin the test. 164 device.reset_input_buffer() 165 166 # Block and wait for the first byte. 167 serial_data += device.read() 168 if not serial_data: 169 raise TestingFailure('Device not producing output') 170 171 device.timeout = test_timeout 172 173 # Read with a reasonable timeout until we stop getting characters. 174 while True: 175 bytes_read = device.readline() 176 if not bytes_read: 177 break 178 serial_data += bytes_read 179 if serial_data.rfind(_TESTS_DONE_STRING) != -1: 180 # Set to much more aggressive timeout since the last one or two 181 # lines should print out immediately. (one line if all fails or all 182 # passes, two lines if mixed.) 183 device.timeout = 0.01 184 185 # Remove carriage returns. 186 serial_data = serial_data.replace(b'\r', b'') 187 188 # Try to trim captured results to only contain most recent test run. 189 test_start_index = serial_data.rfind(_TESTS_STARTING_STRING) 190 return ( 191 serial_data 192 if test_start_index == -1 193 else serial_data[test_start_index:] 194 ) 195 196 197def wait_for_port(port): 198 """Wait for the serial port to be available.""" 199 while port not in [sp.device for sp in serial.tools.list_ports.comports()]: 200 time.sleep(1) 201 202 203def flash_device(test_runner_args, upload_tool): 204 """Flash binary to a connected device using the provided configuration.""" 205 206 # TODO(tonymd): Create a library function to call rather than launching 207 # the arduino_builder script. 208 flash_tool = 'arduino_builder' 209 cmd = ( 210 [flash_tool, "--quiet"] 211 + test_runner_args 212 + ["--run-objcopy", "--run-postbuilds", "--run-upload", upload_tool] 213 ) 214 _LOG.info('Flashing firmware to device') 215 _LOG.debug('Running: %s', " ".join(cmd)) 216 217 env = os.environ.copy() 218 process = subprocess.run( 219 cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env 220 ) 221 if process.returncode: 222 log_subprocess_output(logging.ERROR, process.stdout) 223 raise TestingFailure('Failed to flash target device') 224 225 log_subprocess_output(logging.DEBUG, process.stdout) 226 227 _LOG.debug('Successfully flashed firmware to device') 228 229 230def handle_test_results(test_output): 231 """Parses test output to determine whether tests passed or failed.""" 232 233 if test_output.find(_TESTS_STARTING_STRING) == -1: 234 raise TestingFailure('Failed to find test start') 235 236 if test_output.rfind(_TESTS_DONE_STRING) == -1: 237 log_subprocess_output(logging.INFO, test_output) 238 raise TestingFailure('Tests did not complete') 239 240 if test_output.rfind(_TEST_FAILURE_STRING) != -1: 241 log_subprocess_output(logging.INFO, test_output) 242 raise TestingFailure('Test suite had one or more failures') 243 244 log_subprocess_output(logging.DEBUG, test_output) 245 246 _LOG.info('Test passed!') 247 248 249def run_device_test( 250 binary, 251 flash_only, 252 port, 253 baud, 254 test_timeout, 255 upload_tool, 256 arduino_package_path, 257 test_runner_args, 258) -> bool: 259 """Flashes, runs, and checks an on-device test binary. 260 261 Returns true on test pass. 262 """ 263 if test_runner_args is None: 264 test_runner_args = [] 265 266 if "teensy" not in arduino_package_path: 267 raise ArduinoCoreNotSupported(arduino_package_path) 268 269 if port is None or "--set-variable" not in test_runner_args: 270 _LOG.debug('Attempting to automatically detect dev board') 271 boards = teensy_detector.detect_boards(arduino_package_path) 272 if not boards: 273 error = 'Could not find an attached device' 274 _LOG.error(error) 275 raise DeviceNotFound(error) 276 test_runner_args += boards[0].test_runner_args() 277 upload_tool = boards[0].arduino_upload_tool_name 278 if port is None: 279 port = boards[0].dev_name 280 281 # TODO(tonymd): Remove this when teensy_ports is working in teensy_detector 282 if platform.system() == "Windows": 283 # Delete the incorrect serial port. 284 index_of_port = [ 285 i 286 for i, l in enumerate(test_runner_args) 287 if l.startswith('serial.port=') 288 ] 289 if index_of_port: 290 # Delete the '--set-variable' arg 291 del test_runner_args[index_of_port[0] - 1] 292 # Delete the 'serial.port=*' arg 293 del test_runner_args[index_of_port[0] - 1] 294 295 _LOG.debug('Launching test binary %s', binary) 296 try: 297 result: list[bytes] = [] 298 _LOG.info('Running test') 299 # Warning: A race condition is possible here. This assumes the host is 300 # able to connect to the port and that there isn't a test running on 301 # this serial port. 302 flash_device(test_runner_args, upload_tool) 303 wait_for_port(port) 304 if flash_only: 305 return True 306 result.append(read_serial(port, baud, test_timeout)) 307 if result: 308 handle_test_results(result[0]) 309 except TestingFailure as err: 310 _LOG.error(err) 311 return False 312 313 return True 314 315 316def get_option(key, config_file_values, args, required=False): 317 command_line_option = getattr(args, key, None) 318 final_option = config_file_values.get(key, command_line_option) 319 if required and command_line_option is None and final_option is None: 320 # Print a similar error message to argparse 321 executable = os.path.basename(sys.argv[0]) 322 option = "--" + key.replace("_", "-") 323 print( 324 f"{executable}: error: the following arguments are required: " 325 f"{option}" 326 ) 327 sys.exit(1) 328 return final_option 329 330 331def main(): 332 """Set up runner, and then flash/run device test.""" 333 args = parse_args() 334 335 json_file_options, unused_config_path = decode_file_json(args.config_file) 336 337 log_level = logging.DEBUG if args.verbose else logging.INFO 338 pw_arduino_build.log.install(log_level) 339 340 # Construct arduino_builder flash arguments for a given .elf binary. 341 arduino_package_path = get_option( 342 "arduino_package_path", json_file_options, args, required=True 343 ) 344 # Arduino core args. 345 arduino_builder_args = [ 346 "--arduino-package-path", 347 arduino_package_path, 348 "--arduino-package-name", 349 get_option( 350 "arduino_package_name", json_file_options, args, required=True 351 ), 352 ] 353 354 # Use CIPD installed compilers. 355 compiler_path_override = get_option( 356 "compiler_path_override", json_file_options, args 357 ) 358 if compiler_path_override: 359 arduino_builder_args += [ 360 "--compiler-path-override", 361 compiler_path_override, 362 ] 363 364 # Run subcommand with board selection arg. 365 arduino_builder_args += [ 366 "run", 367 "--board", 368 get_option("board", json_file_options, args, required=True), 369 ] 370 371 # .elf file location args. 372 binary = args.binary 373 build_path = binary.parent.as_posix() 374 arduino_builder_args += ["--build-path", build_path] 375 build_project_name = binary.name 376 # Remove '.elf' extension. 377 match_result = re.match(r'(.*?)\.elf$', binary.name, re.IGNORECASE) 378 if match_result: 379 build_project_name = match_result[1] 380 arduino_builder_args += ["--build-project-name", build_project_name] 381 382 # USB port is passed to arduino_builder_args via --set-variable args. 383 if args.set_variable: 384 for var in args.set_variable: 385 arduino_builder_args += ["--set-variable", var] 386 387 if run_device_test( 388 binary.as_posix(), 389 args.flash_only, 390 args.port, 391 args.baud, 392 args.test_timeout, 393 args.upload_tool, 394 arduino_package_path, 395 test_runner_args=arduino_builder_args, 396 ): 397 sys.exit(0) 398 else: 399 sys.exit(1) 400 401 402if __name__ == '__main__': 403 main() 404