diff --git a/HISTORY.rst b/HISTORY.rst index a3a0661b..50efce56 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -16,7 +16,13 @@ PlatformIO Core 6 6.0.3 (2022-??-??) ~~~~~~~~~~~~~~~~~~ -- Fixed an issue when a custom `pio test --project-config `__ was not handled properly (`issue #4299 `_) +* **Device Monitor** + + - Automatically reconnect if a connection fails + - Added new `pio device monitor --no-reconnect `__ option to disable automatic reconnection + - Handle disconnects more gracefully (`issue #3939 `_) + +* Fixed an issue when a custom `pio test --project-config `__ was not handled properly (`issue #4299 `_) 6.0.2 (2022-06-01) ~~~~~~~~~~~~~~~~~~ diff --git a/docs b/docs index 300060ea..c86b25dd 160000 --- a/docs +++ b/docs @@ -1 +1 @@ -Subproject commit 300060ea08be494465b03b427186bee66eda1766 +Subproject commit c86b25dd81f4bfec721a66415f052d2c20d9e044 diff --git a/platformio/device/monitor/command.py b/platformio/device/monitor/command.py index a4abd767..ae63c1a6 100644 --- a/platformio/device/monitor/command.py +++ b/platformio/device/monitor/command.py @@ -13,14 +13,13 @@ # limitations under the License. import os -import sys import click -from serial.tools import miniterm from platformio import exception, fs from platformio.device.finder import find_serial_port 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.project.config import ProjectConfig 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.option("--port", "-p", help="Port, a number or a device name") @click.option( - "--baud", "-b", + "--baud", 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( "--parity", @@ -58,7 +58,9 @@ from platformio.project.options import ProjectOptions help="Set the encoding for the serial port (e.g. hexlify, " "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( "--eol", default="CRLF", @@ -78,13 +80,21 @@ from platformio.project.options import ProjectOptions type=int, default=20, help="ASCII code of special character that is used to " - "control miniterm (menu), default=20 (DEC)", + "control terminal (menu), default=20 (DEC)", ) @click.option( "--quiet", is_flag=True, 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( "-d", "--project-dir", @@ -96,49 +106,32 @@ from platformio.project.options import ProjectOptions "--environment", help="Load configuration from `platformio.ini` and specified environment", ) -def device_monitor_cmd(**kwargs): # pylint: disable=too-many-branches - project_options = {} +def device_monitor_cmd(**options): platform = None - with fs.cd(kwargs["project_dir"]): + project_options = {} + with fs.cd(options["project_dir"]): try: - project_options = get_project_options(kwargs["environment"]) - kwargs = apply_project_monitor_options(kwargs, project_options) + project_options = get_project_options(options["environment"]) + options = apply_project_monitor_options(options, project_options) if "platform" in project_options: platform = PlatformFactory.new(project_options["platform"]) except NotPlatformIOProjectError: pass - register_filters(platform=platform, options=kwargs) - kwargs["port"] = find_serial_port( - initial_port=kwargs["port"], + register_filters(platform=platform, options=options) + options["port"] = find_serial_port( + initial_port=options["port"], board_config=platform.board_config(project_options.get("board")) if platform and project_options.get("board") else None, upload_protocol=project_options.get("upload_protocol"), ) - # override system argv with patched options - sys.argv = ["monitor"] + project_options_to_monitor_argv( - kwargs, - project_options, - ignore=("port", "baud", "rts", "dtr", "environment", "project_dir"), - ) + if options["menu_char"] == options["exit_char"]: + raise exception.UserSideException( + "--exit-char can not be the same as --menu-char" + ) - if not kwargs["quiet"]: - 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) + start_terminal(options) def get_project_options(environment=None): @@ -148,37 +141,13 @@ def get_project_options(environment=None): 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"): k2 = "monitor_%s" % k if k == "speed": k = "baud" - if cli_options[k] is None and k2 in project_options: - cli_options[k] = project_options[k2] + if initial_options[k] is None and k2 in project_options: + initial_options[k] = project_options[k2] if k != "port": - cli_options[k] = int(cli_options[k]) - return cli_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 + initial_options[k] = int(initial_options[k]) + return initial_options diff --git a/platformio/device/monitor/terminal.py b/platformio/device/monitor/terminal.py new file mode 100644 index 00000000..8da37f7a --- /dev/null +++ b/platformio/device/monitor/terminal.py @@ -0,0 +1,174 @@ +# Copyright (c) 2014-present PlatformIO +# +# 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 diff --git a/platformio/exception.py b/platformio/exception.py index 5c0b44ea..a8287c04 100644 --- a/platformio/exception.py +++ b/platformio/exception.py @@ -30,10 +30,6 @@ class ReturnErrorCode(PlatformioException): MESSAGE = "{0}" -class MinitermException(PlatformioException): - pass - - class UserSideException(PlatformioException): pass diff --git a/platformio/remote/cli.py b/platformio/remote/cli.py index 40bf0b8f..25f53800 100644 --- a/platformio/remote/cli.py +++ b/platformio/remote/cli.py @@ -28,7 +28,6 @@ from platformio.device.monitor.command import ( apply_project_monitor_options, device_monitor_cmd, get_project_options, - project_options_to_monitor_argv, ) from platformio.package.manager.core import inject_contrib_pysite from platformio.project.exception import NotPlatformIOProjectError @@ -360,6 +359,7 @@ def device_monitor(ctx, agents, **kwargs): pass kwargs["baud"] = kwargs["baud"] or ProjectOptions["env.monitor_speed"].default + kwargs["reconnect"] = False def _tx_target(sock_dir): subcmd_argv = ["remote"] @@ -387,3 +387,27 @@ def device_monitor(ctx, agents, **kwargs): fs.rmtree(sock_dir) 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 diff --git a/setup.py b/setup.py index a49c1bc9..eea95656 100644 --- a/setup.py +++ b/setup.py @@ -32,7 +32,7 @@ minimal_requirements = [ "colorama", "marshmallow==%s" % ("3.*" if sys.version_info >= (3, 7) else "3.14.1"), "pyelftools>=0.27,<1", - "pyserial==3.*", + "pyserial==3.5.*", # keep in sync "device/monitor/terminal.py" "requests==2.*", "requests==%s" % ("2.*" if sys.version_info >= (3, 7) else "2.27.1"), "semantic_version==2.10.*",