Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Better version handling for Arduino #11043

Merged
merged 8 commits into from
Apr 20, 2022
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 55 additions & 45 deletions apps/microtvm/arduino/template_project/microtvm_api_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@
from string import Template
import re

import serial
import serial.tools.list_ports

from tvm.micro.project_api import server

_LOG = logging.getLogger(__name__)
Expand All @@ -46,10 +46,7 @@

IS_TEMPLATE = not (API_SERVER_DIR / MODEL_LIBRARY_FORMAT_RELPATH).exists()

# Used to check Arduino CLI version installed on the host.
# We only check two levels of the version.
ARDUINO_CLI_VERSION = 0.18

MIN_ARDUINO_CLI_VERSION = 0.18

BOARDS = API_SERVER_DIR / "boards.json"

Expand Down Expand Up @@ -126,6 +123,7 @@ def __init__(self):
self._proc = None
self._port = None
self._serial = None
self._version = None

def server_info_query(self, tvm_version):
return server.ServerInfo(
Expand Down Expand Up @@ -314,25 +312,7 @@ def _find_modified_include_path(self, project_dir, file_path, include_path):
# It's probably a standard C/C++ header
return include_path

def _get_platform_version(self, arduino_cli_path: str) -> float:
# sample output of this command:
# 'arduino-cli alpha Version: 0.18.3 Commit: d710b642 Date: 2021-05-14T12:36:58Z\n'
version_output = subprocess.check_output([arduino_cli_path, "version"], encoding="utf-8")
full_version = re.findall("version: ([\.0-9]*)", version_output.lower())
full_version = full_version[0].split(".")
version = float(f"{full_version[0]}.{full_version[1]}")

return version

def generate_project(self, model_library_format_path, standalone_crt_dir, project_dir, options):
# Check Arduino version
version = self._get_platform_version(self._get_arduino_cli_cmd(options))
if version != ARDUINO_CLI_VERSION:
message = f"Arduino CLI version found is not supported: found {version}, expected {ARDUINO_CLI_VERSION}."
if options.get("warning_as_error") is not None and options["warning_as_error"]:
raise server.ServerError(message=message)
_LOG.warning(message)

# Reference key directories with pathlib
project_dir = pathlib.Path(project_dir)
project_dir.mkdir()
Expand Down Expand Up @@ -368,11 +348,44 @@ def generate_project(self, model_library_format_path, standalone_crt_dir, projec
# Recursively change includes
self._convert_includes(project_dir, source_dir)

def _get_arduino_cli_cmd(self, options: dict):
arduino_cli_cmd = options.get("arduino_cli_cmd", ARDUINO_CLI_CMD)
assert arduino_cli_cmd, "'arduino_cli_cmd' command not passed and not found by default!"
return arduino_cli_cmd

def _get_platform_version(self, arduino_cli_path: str) -> float:
# sample output of this command:
# 'arduino-cli alpha Version: 0.18.3 Commit: d710b642 Date: 2021-05-14T12:36:58Z\n'
version_output = subprocess.run(
[arduino_cli_path, "version"], check=True, stdout=subprocess.PIPE
).stdout.decode("utf-8")

full_version = re.findall(r"version: ([\.0-9]*)", version_output.lower())
full_version = full_version[0].split(".")
version = float(f"{full_version[0]}.{full_version[1]}")
return version

# This will only be run for build and upload
def _check_platform_version(self, options):
if not self._version:
cli_command = self._get_arduino_cli_cmd(options)
self._version = self._get_platform_version(cli_command)

if self._version < MIN_ARDUINO_CLI_VERSION:
message = (
f"Arduino CLI version too old: found {self._version}, "
f"need at least {ARDUINO_CLI_VERSION}."
guberti marked this conversation as resolved.
Show resolved Hide resolved
)
if options.get("warning_as_error") is not None and options["warning_as_error"]:
raise server.ServerError(message=message)
_LOG.warning(message)

def _get_fqbn(self, options):
o = BOARD_PROPERTIES[options["arduino_board"]]
return f"{o['package']}:{o['architecture']}:{o['board']}"

def build(self, options):
self._check_platform_version(options)
guberti marked this conversation as resolved.
Show resolved Hide resolved
BUILD_DIR.mkdir()

compile_cmd = [
Expand All @@ -391,19 +404,14 @@ def build(self, options):
# Specify project to compile
subprocess.run(compile_cmd, check=True)

BOARD_LIST_HEADERS = ("Port", "Type", "Board Name", "FQBN", "Core")
POSSIBLE_BOARD_LIST_HEADERS = ("Port", "Protocol", "Type", "Board Name", "FQBN", "Core")

def _get_arduino_cli_cmd(self, options: dict):
arduino_cli_cmd = options.get("arduino_cli_cmd", ARDUINO_CLI_CMD)
assert arduino_cli_cmd, "'arduino_cli_cmd' command not passed and not found by default!"
return arduino_cli_cmd

def _parse_boards_tabular_str(self, tabular_str):
def _parse_connected_boards(self, tabular_str):
"""Parses the tabular output from `arduino-cli board list` into a 2D array

Examples
--------
>>> list(_parse_boards_tabular_str(bytes(
>>> list(_parse_connected_boards(bytes(
... "Port Type Board Name FQBN Core \n"
... "/dev/ttyS4 Serial Port Unknown \n"
... "/dev/ttyUSB0 Serial Port (USB) Spresense SPRESENSE:spresense:spresense SPRESENSE:spresense\n"
Expand All @@ -414,20 +422,21 @@ def _parse_boards_tabular_str(self, tabular_str):

"""

str_rows = tabular_str.split("\n")[:-2]
header = str_rows[0]
indices = [header.index(h) for h in self.BOARD_LIST_HEADERS] + [len(header)]
# Which column headers are present depends on the version of arduino-cli
column_regex = r"\s*|".join(self.POSSIBLE_BOARD_LIST_HEADERS) + r"\s*"
str_rows = tabular_str.split("\n")
column_headers = list(re.finditer(column_regex, str_rows[0]))
assert len(column_headers) > 0

for str_row in str_rows[1:]:
parsed_row = []
for cell_index in range(len(self.BOARD_LIST_HEADERS)):
start = indices[cell_index]
end = indices[cell_index + 1]
str_cell = str_row[start:end]
if not str_row.strip():
continue
device = {}

# Remove trailing whitespace used for padding
parsed_row.append(str_cell.rstrip())
yield parsed_row
for column in column_headers:
col_name = column.group(0).strip().lower()
device[col_name] = str_row[column.start() : column.end()].strip()
yield device

def _auto_detect_port(self, options):
list_cmd = [self._get_arduino_cli_cmd(options), "board", "list"]
Expand All @@ -436,9 +445,9 @@ def _auto_detect_port(self, options):
).stdout.decode("utf-8")

desired_fqbn = self._get_fqbn(options)
for line in self._parse_boards_tabular_str(list_cmd_output):
if line[3] == desired_fqbn:
return line[0]
for device in self._parse_connected_boards(list_cmd_output):
if device["fqbn"] == desired_fqbn:
return device["port"]

# If no compatible boards, raise an error
raise BoardAutodetectFailed()
Expand All @@ -453,6 +462,7 @@ def _get_arduino_port(self, options):
return self._port

def flash(self, options):
self._check_platform_version(options)
port = self._get_arduino_port(options)

upload_cmd = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,53 +63,89 @@ def test_find_modified_include_path(self, mock_pathlib_path):
)
assert valid_output == valid_arduino_import

BOARD_CONNECTED_OUTPUT = bytes(
# Format for arduino-cli v0.18.2
BOARD_CONNECTED_V18 = (
"Port Type Board Name FQBN Core \n"
"/dev/ttyACM0 Serial Port (USB) Arduino Nano 33 BLE arduino:mbed_nano:nano33ble arduino:mbed_nano\n"
"/dev/ttyACM1 Serial Port (USB) Arduino Nano 33 arduino:mbed_nano:nano33 arduino:mbed_nano\n"
"/dev/ttyS4 Serial Port Unknown \n"
"\n",
"utf-8",
"\n"
)
BOARD_DISCONNECTED_OUTPUT = bytes(
"Port Type Board Name FQBN Core\n"
"/dev/ttyS4 Serial Port Unknown \n"
"\n",
"utf-8",
# Format for arduino-cli v0.21.1 and above
BOARD_CONNECTED_V21 = (
"Port Protocol Type Board Name FQBN Core \n"
"/dev/ttyACM0 serial arduino:mbed_nano:nano33ble arduino:mbed_nano\n"
"\n"
)
BOARD_DISCONNECTED_V21 = (
"Port Protocol Type Board Name FQBN Core\n"
"/dev/ttyS4 serial Serial Port Unknown\n"
"\n"
)

def test_parse_connected_boards(self):
h = microtvm_api_server.Handler()
boards = h._parse_connected_boards(self.BOARD_CONNECTED_V21)
assert list(boards) == [
{
"port": "/dev/ttyACM0",
"protocol": "serial",
"type": "",
"board name": "",
"fqbn": "arduino:mbed_nano:nano33ble",
"core": "arduino:mbed_nano",
}
]

@mock.patch("subprocess.run")
def test_auto_detect_port(self, mock_subprocess_run):
def test_auto_detect_port(self, sub_mock):
process_mock = mock.Mock()
handler = microtvm_api_server.Handler()

# Test it returns the correct port when a board is connected
mock_subprocess_run.return_value.stdout = self.BOARD_CONNECTED_OUTPUT
sub_mock.return_value.stdout = bytes(self.BOARD_CONNECTED_V18, "utf-8")
assert handler._auto_detect_port(self.DEFAULT_OPTIONS) == "/dev/ttyACM0"

# Test it raises an exception when no board is connected
mock_subprocess_run.return_value.stdout = self.BOARD_DISCONNECTED_OUTPUT
sub_mock.return_value.stdout = bytes(self.BOARD_CONNECTED_V21, "utf-8")
guberti marked this conversation as resolved.
Show resolved Hide resolved
assert handler._auto_detect_port(self.DEFAULT_OPTIONS) == "/dev/ttyACM0"

# Should work with old or new arduino-cli version
sub_mock.return_value.stdout = bytes(self.BOARD_DISCONNECTED_V21, "utf-8")
with pytest.raises(microtvm_api_server.BoardAutodetectFailed):
handler._auto_detect_port(self.DEFAULT_OPTIONS)

# Test that the FQBN needs to match EXACTLY
handler._get_fqbn = mock.MagicMock(return_value="arduino:mbed_nano:nano33")
mock_subprocess_run.return_value.stdout = self.BOARD_CONNECTED_OUTPUT
sub_mock.return_value.stdout = bytes(self.BOARD_CONNECTED_V18, "utf-8")
assert (
handler._auto_detect_port({**self.DEFAULT_OPTIONS, "arduino_board": "nano33"})
== "/dev/ttyACM1"
)

CLI_VERSION = "arduino-cli Version: 0.21.1 Commit: 9fcbb392 Date: 2022-02-24T15:41:45Z\n"

@mock.patch("subprocess.run")
def test_flash(self, mock_subprocess_run):
def test_flash(self, mock_run):
mock_run.return_value.stdout = bytes(self.CLI_VERSION, "utf-8")

handler = microtvm_api_server.Handler()
handler._port = "/dev/ttyACM0"

# Test no exception thrown when command works
handler.flash(self.DEFAULT_OPTIONS)
mock_subprocess_run.assert_called_once()

# Test we checked version then called upload
assert mock_run.call_count == 2
assert mock_run.call_args_list[0][0] == (["arduino-cli", "version"],)
assert mock_run.call_args_list[1][0][0][0:2] == ["arduino-cli", "upload"]
mock_run.reset_mock()

# Test exception raised when `arduino-cli upload` returns error code
mock_subprocess_run.side_effect = subprocess.CalledProcessError(2, [])
mock_run.side_effect = subprocess.CalledProcessError(2, [])
with pytest.raises(subprocess.CalledProcessError):
handler.flash(self.DEFAULT_OPTIONS)

# Version information should be cached and not checked again
mock_run.assert_called_once()
assert mock_run.call_args[0][0][0:2] == ["arduino-cli", "upload"]