Automatically reconnect device monitor if a connection fails // Resolve #3939

This commit is contained in:
Ivan Kravets
2022-06-11 20:55:52 +03:00
parent c42fe32972
commit 7f351bc7c8
7 changed files with 242 additions and 73 deletions

View File

@ -16,7 +16,13 @@ PlatformIO Core 6
6.0.3 (2022-??-??) 6.0.3 (2022-??-??)
~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~
- Fixed an issue when a custom `pio test --project-config <https://docs.platformio.org/en/latest/core/userguide/cmd_test.html#cmdoption-pio-test-c>`__ was not handled properly (`issue #4299 <https://github.com/platformio/platformio-core/issues/4299>`_) * **Device Monitor**
- Automatically reconnect if a connection fails
- Added new `pio device monitor --no-reconnect <https://docs.platformio.org/en/latest/core/userguide/device/cmd_monitor.html#cmdoption-pio-device-monitor-reconnect-no-reconnect>`__ option to disable automatic reconnection
- Handle disconnects more gracefully (`issue #3939 <https://github.com/platformio/platformio-core/issues/3939>`_)
* Fixed an issue when a custom `pio test --project-config <https://docs.platformio.org/en/latest/core/userguide/cmd_test.html#cmdoption-pio-test-c>`__ was not handled properly (`issue #4299 <https://github.com/platformio/platformio-core/issues/4299>`_)
6.0.2 (2022-06-01) 6.0.2 (2022-06-01)
~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~

2
docs

Submodule docs updated: 300060ea08...c86b25dd81

View File

@ -13,14 +13,13 @@
# limitations under the License. # limitations under the License.
import os import os
import sys
import click import click
from serial.tools import miniterm
from platformio import exception, fs from platformio import exception, fs
from platformio.device.finder import find_serial_port from platformio.device.finder import find_serial_port
from platformio.device.monitor.filters.base import register_filters from platformio.device.monitor.filters.base import register_filters
from platformio.device.monitor.terminal import start_terminal
from platformio.platform.factory import PlatformFactory from platformio.platform.factory import PlatformFactory
from platformio.project.config import ProjectConfig from platformio.project.config import ProjectConfig
from platformio.project.exception import NotPlatformIOProjectError from platformio.project.exception import NotPlatformIOProjectError
@ -30,10 +29,11 @@ from platformio.project.options import ProjectOptions
@click.command("monitor", short_help="Monitor device (Serial/Socket)") @click.command("monitor", short_help="Monitor device (Serial/Socket)")
@click.option("--port", "-p", help="Port, a number or a device name") @click.option("--port", "-p", help="Port, a number or a device name")
@click.option( @click.option(
"--baud",
"-b", "-b",
"--baud",
type=int, type=int,
help="Set baud rate, default=%d" % ProjectOptions["env.monitor_speed"].default, default=ProjectOptions["env.monitor_speed"].default,
help="Set baud/speed, default=%d" % ProjectOptions["env.monitor_speed"].default,
) )
@click.option( @click.option(
"--parity", "--parity",
@ -58,7 +58,9 @@ from platformio.project.options import ProjectOptions
help="Set the encoding for the serial port (e.g. hexlify, " help="Set the encoding for the serial port (e.g. hexlify, "
"Latin1, UTF-8), default: UTF-8", "Latin1, UTF-8), default: UTF-8",
) )
@click.option("--filter", "-f", multiple=True, help="Add filters/text transformations") @click.option(
"-f", "--filter", "filters", multiple=True, help="Add filters/text transformations"
)
@click.option( @click.option(
"--eol", "--eol",
default="CRLF", default="CRLF",
@ -78,13 +80,21 @@ from platformio.project.options import ProjectOptions
type=int, type=int,
default=20, default=20,
help="ASCII code of special character that is used to " help="ASCII code of special character that is used to "
"control miniterm (menu), default=20 (DEC)", "control terminal (menu), default=20 (DEC)",
) )
@click.option( @click.option(
"--quiet", "--quiet",
is_flag=True, is_flag=True,
help="Diagnostics: suppress non-error messages, default=Off", help="Diagnostics: suppress non-error messages, default=Off",
) )
@click.option(
"--reconnect/--no-reconnect",
default=True,
help=(
"If established connection fails, "
"silently retry on the same port, default=True"
),
)
@click.option( @click.option(
"-d", "-d",
"--project-dir", "--project-dir",
@ -96,49 +106,32 @@ from platformio.project.options import ProjectOptions
"--environment", "--environment",
help="Load configuration from `platformio.ini` and specified environment", help="Load configuration from `platformio.ini` and specified environment",
) )
def device_monitor_cmd(**kwargs): # pylint: disable=too-many-branches def device_monitor_cmd(**options):
project_options = {}
platform = None platform = None
with fs.cd(kwargs["project_dir"]): project_options = {}
with fs.cd(options["project_dir"]):
try: try:
project_options = get_project_options(kwargs["environment"]) project_options = get_project_options(options["environment"])
kwargs = apply_project_monitor_options(kwargs, project_options) options = apply_project_monitor_options(options, project_options)
if "platform" in project_options: if "platform" in project_options:
platform = PlatformFactory.new(project_options["platform"]) platform = PlatformFactory.new(project_options["platform"])
except NotPlatformIOProjectError: except NotPlatformIOProjectError:
pass pass
register_filters(platform=platform, options=kwargs) register_filters(platform=platform, options=options)
kwargs["port"] = find_serial_port( options["port"] = find_serial_port(
initial_port=kwargs["port"], initial_port=options["port"],
board_config=platform.board_config(project_options.get("board")) board_config=platform.board_config(project_options.get("board"))
if platform and project_options.get("board") if platform and project_options.get("board")
else None, else None,
upload_protocol=project_options.get("upload_protocol"), upload_protocol=project_options.get("upload_protocol"),
) )
# override system argv with patched options if options["menu_char"] == options["exit_char"]:
sys.argv = ["monitor"] + project_options_to_monitor_argv( raise exception.UserSideException(
kwargs, "--exit-char can not be the same as --menu-char"
project_options, )
ignore=("port", "baud", "rts", "dtr", "environment", "project_dir"),
)
if not kwargs["quiet"]: start_terminal(options)
click.echo(
"--- Available filters and text transformations: %s"
% ", ".join(sorted(miniterm.TRANSFORMATIONS.keys()))
)
click.echo("--- More details at https://bit.ly/pio-monitor-filters")
try:
miniterm.main(
default_port=kwargs["port"],
default_baudrate=kwargs["baud"]
or ProjectOptions["env.monitor_speed"].default,
default_rts=kwargs["rts"],
default_dtr=kwargs["dtr"],
)
except Exception as e:
raise exception.MinitermException(e)
def get_project_options(environment=None): def get_project_options(environment=None):
@ -148,37 +141,13 @@ def get_project_options(environment=None):
return config.items(env=environment, as_dict=True) return config.items(env=environment, as_dict=True)
def apply_project_monitor_options(cli_options, project_options): def apply_project_monitor_options(initial_options, project_options):
for k in ("port", "speed", "rts", "dtr"): for k in ("port", "speed", "rts", "dtr"):
k2 = "monitor_%s" % k k2 = "monitor_%s" % k
if k == "speed": if k == "speed":
k = "baud" k = "baud"
if cli_options[k] is None and k2 in project_options: if initial_options[k] is None and k2 in project_options:
cli_options[k] = project_options[k2] initial_options[k] = project_options[k2]
if k != "port": if k != "port":
cli_options[k] = int(cli_options[k]) initial_options[k] = int(initial_options[k])
return cli_options return initial_options
def project_options_to_monitor_argv(cli_options, project_options, ignore=None):
confmon_flags = project_options.get("monitor_flags", [])
result = confmon_flags[::]
for f in project_options.get("monitor_filters", []):
result.extend(["--filter", f])
for k, v in cli_options.items():
if v is None or (ignore and k in ignore):
continue
k = "--" + k.replace("_", "-")
if k in confmon_flags:
continue
if isinstance(v, bool):
if v:
result.append(k)
elif isinstance(v, tuple):
for i in v:
result.extend([k, i])
else:
result.extend([k, str(v)])
return result

View File

@ -0,0 +1,174 @@
# Copyright (c) 2014-present PlatformIO <contact@platformio.org>
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import signal
import threading
import click
import serial
from serial.tools import miniterm
from platformio.exception import UserSideException
class Terminal(miniterm.Miniterm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.pio_unexpected_exception = None
def reader(self):
try:
super().reader()
except Exception as exc: # pylint: disable=broad-except
self.pio_unexpected_exception = exc
def writer(self):
try:
super().writer()
except Exception as exc: # pylint: disable=broad-except
self.pio_unexpected_exception = exc
def start_terminal(options):
retries = 0
is_port_valid = False
while True:
term = None
try:
term = new_terminal(options)
is_port_valid = True
options["port"] = term.serial.name
if retries:
click.echo("\t Connected!", err=True)
elif not options["quiet"]:
print_terminal_settings(term)
retries = 0 # reset
term.start()
try:
term.join(True)
except KeyboardInterrupt:
pass
term.join()
term.console.cleanup()
term.close()
if term.pio_unexpected_exception:
click.secho(
"Disconnected (%s)" % term.pio_unexpected_exception,
fg="red",
err=True,
)
if options["reconnect"]:
raise UserSideException(term.pio_unexpected_exception)
return
except UserSideException as exc:
if not is_port_valid:
raise exc
if not retries:
click.echo("Reconnecting to %s " % options["port"], err=True, nl=False)
signal.signal(signal.SIGINT, signal.SIG_DFL)
else:
click.echo(".", err=True, nl=False)
retries += 1
threading.Event().wait(retries / 2)
def new_terminal(options):
term = Terminal(
new_serial_instance(options),
echo=options["echo"],
eol=options["eol"].lower(),
filters=options["filters"] or ["default"],
)
term.exit_character = chr(options["exit_char"])
term.menu_character = chr(options["menu_char"])
term.raw = options["raw"]
term.set_rx_encoding(options["encoding"])
term.set_tx_encoding(options["encoding"])
return term
def print_terminal_settings(terminal):
click.echo(
"--- Terminal on {p.name} | "
"{p.baudrate} {p.bytesize}-{p.parity}-{p.stopbits}".format(p=terminal.serial)
)
click.echo(
"--- Available filters and text transformations: %s"
% ", ".join(sorted(miniterm.TRANSFORMATIONS.keys()))
)
click.echo("--- More details at https://bit.ly/pio-monitor-filters")
click.echo(
"--- Quit: {} | Menu: {} | Help: {} followed by {}".format(
miniterm.key_description(terminal.exit_character),
miniterm.key_description(terminal.menu_character),
miniterm.key_description(terminal.menu_character),
miniterm.key_description("\x08"),
)
)
def new_serial_instance(options): # pylint: disable=too-many-branches
serial_instance = None
port = options["port"]
while serial_instance is None:
# no port given on command line -> ask user now
if port is None or port == "-":
try:
port = miniterm.ask_for_port()
except KeyboardInterrupt:
click.echo("", err=True)
raise UserSideException("User aborted and port is not given")
else:
if not port:
raise UserSideException("Port is not given")
try:
serial_instance = serial.serial_for_url(
port,
options["baud"],
parity=options["parity"],
rtscts=options["rtscts"],
xonxoff=options["xonxoff"],
do_not_open=True,
)
if not hasattr(serial_instance, "cancel_read"):
# enable timeout for alive flag polling if cancel_read is not available
serial_instance.timeout = 1
if options["dtr"] is not None:
if not options["quiet"]:
click.echo(
"--- forcing DTR {}".format(
"active" if options["dtr"] else "inactive"
)
)
serial_instance.dtr = options["dtr"]
if options["rts"] is not None:
if not options["quiet"]:
click.echo(
"--- forcing RTS {}".format(
"active" if options["rts"] else "inactive"
)
)
serial_instance.rts = options["rts"]
if isinstance(serial_instance, serial.Serial):
serial_instance.exclusive = True
serial_instance.open()
except serial.SerialException as exc:
raise UserSideException(exc)
return serial_instance

View File

@ -30,10 +30,6 @@ class ReturnErrorCode(PlatformioException):
MESSAGE = "{0}" MESSAGE = "{0}"
class MinitermException(PlatformioException):
pass
class UserSideException(PlatformioException): class UserSideException(PlatformioException):
pass pass

View File

@ -28,7 +28,6 @@ from platformio.device.monitor.command import (
apply_project_monitor_options, apply_project_monitor_options,
device_monitor_cmd, device_monitor_cmd,
get_project_options, get_project_options,
project_options_to_monitor_argv,
) )
from platformio.package.manager.core import inject_contrib_pysite from platformio.package.manager.core import inject_contrib_pysite
from platformio.project.exception import NotPlatformIOProjectError from platformio.project.exception import NotPlatformIOProjectError
@ -360,6 +359,7 @@ def device_monitor(ctx, agents, **kwargs):
pass pass
kwargs["baud"] = kwargs["baud"] or ProjectOptions["env.monitor_speed"].default kwargs["baud"] = kwargs["baud"] or ProjectOptions["env.monitor_speed"].default
kwargs["reconnect"] = False
def _tx_target(sock_dir): def _tx_target(sock_dir):
subcmd_argv = ["remote"] subcmd_argv = ["remote"]
@ -387,3 +387,27 @@ def device_monitor(ctx, agents, **kwargs):
fs.rmtree(sock_dir) fs.rmtree(sock_dir)
return True return True
def project_options_to_monitor_argv(cli_options, project_options, ignore=None):
confmon_flags = project_options.get("monitor_flags", [])
result = confmon_flags[::]
for f in project_options.get("monitor_filters", []):
result.extend(["--filter", f])
for k, v in cli_options.items():
if v is None or (ignore and k in ignore):
continue
k = "--" + k.replace("_", "-")
if k in confmon_flags:
continue
if isinstance(v, bool):
if v:
result.append(k)
elif isinstance(v, tuple):
for i in v:
result.extend([k, i])
else:
result.extend([k, str(v)])
return result

View File

@ -32,7 +32,7 @@ minimal_requirements = [
"colorama", "colorama",
"marshmallow==%s" % ("3.*" if sys.version_info >= (3, 7) else "3.14.1"), "marshmallow==%s" % ("3.*" if sys.version_info >= (3, 7) else "3.14.1"),
"pyelftools>=0.27,<1", "pyelftools>=0.27,<1",
"pyserial==3.*", "pyserial==3.5.*", # keep in sync "device/monitor/terminal.py"
"requests==2.*", "requests==2.*",
"requests==%s" % ("2.*" if sys.version_info >= (3, 7) else "2.27.1"), "requests==%s" % ("2.*" if sys.version_info >= (3, 7) else "2.27.1"),
"semantic_version==2.10.*", "semantic_version==2.10.*",