diff --git a/ebuild/cli/commands.py b/ebuild/cli/commands.py index 6c99e2b..64be4e8 100644 --- a/ebuild/cli/commands.py +++ b/ebuild/cli/commands.py @@ -3074,7 +3074,8 @@ def _resolve_test_runner( @cli.command() @click.option("--port", default=None, - help="Serial device (e.g. /dev/ttyUSB0). Auto-detected if omitted.") + help="Serial device (e.g. /dev/ttyUSB0 or COM3). " + "Auto-detected if omitted.") @click.option("--baud", default=115200, type=int, help="Baud rate.") @click.pass_obj def monitor(log: Logger, port: Optional[str], baud: int) -> None: @@ -3086,12 +3087,30 @@ def monitor(log: Logger, port: Optional[str], baud: int) -> None: log.header("ebuild — Monitor") log.info(f"Board: {_selected_board()}") + # On Windows, COM ports can only be found through pyserial, so a missing + # pyserial is reported before auto-detection there — otherwise it would + # be misreported as "No serial device found". On POSIX, detection is by + # glob and does not need pyserial, so there the missing-pyserial report + # stays after selection (the #83 contract: no device and no pyserial + # still reports the missing device). + try: + import serial # type: ignore[import-untyped] + except ImportError: + if sys.platform.startswith("win"): + log.error( + "pyserial is not installed, so the monitor cannot open the port.\n" + " Install it with: pip install pyserial" + ) + raise SystemExit(1) + serial = None # type: ignore[assignment] + if port is None: candidates = _serial_ports() if not candidates: log.error( "No serial device found.\n" - " Looked for /dev/ttyUSB*, /dev/ttyACM*, /dev/tty.usb*.\n" + " Looked for /dev/ttyUSB*, /dev/ttyACM*, /dev/tty.usb*, " + "and USB COM ports.\n" " Connect the board, or name the device with --port." ) raise SystemExit(1) @@ -3106,17 +3125,15 @@ def monitor(log: Logger, port: Optional[str], baud: int) -> None: port = candidates[0] log.info(f"Auto-detected {port}") - log.step(f"Opening {port} at {baud} baud... (Ctrl-C to exit)") - - try: - import serial # type: ignore[import-untyped] - except ImportError: + if serial is None: log.error( "pyserial is not installed, so the monitor cannot open the port.\n" " Install it with: pip install pyserial" ) raise SystemExit(1) + log.step(f"Opening {port} at {baud} baud... (Ctrl-C to exit)") + try: with serial.Serial(port, baud, timeout=0.2) as conn: while True: @@ -3135,8 +3152,30 @@ def monitor(log: Logger, port: Optional[str], baud: int) -> None: def _serial_ports() -> List[str]: """Serial devices that look like an attached development board.""" found: List[str] = [] - for pattern in ("/dev/ttyUSB*", "/dev/ttyACM*", "/dev/tty.usb*"): + + for pattern in ( + "/dev/ttyUSB*", + "/dev/ttyACM*", + "/dev/tty.usb*", + ): found.extend(sorted(glob.glob(pattern))) + + # Windows names boards COMn, and only pyserial can enumerate them. Keep + # USB devices only (those with a vendor ID): built-in COM1 and Bluetooth + # ports would otherwise make auto-detection ambiguous, which is the same + # reason /dev/ttyS* is not searched above. + try: + from serial.tools import list_ports + except ImportError: + return found + + com_ports = [ + port.device + for port in list_ports.comports() + if port.device.upper().startswith("COM") and port.vid is not None + ] + # Sort by length first so COM2 precedes COM10. + found.extend(sorted(com_ports, key=lambda d: (len(d), d))) return found diff --git a/tests/unit/cli/test_serial_ports.py b/tests/unit/cli/test_serial_ports.py new file mode 100644 index 0000000..f275559 --- /dev/null +++ b/tests/unit/cli/test_serial_ports.py @@ -0,0 +1,158 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project + +import sys +import types + +from click.testing import CliRunner +import pytest + +import ebuild.cli.commands as commands +from ebuild.cli.commands import _serial_ports, monitor + + +class RecordingLogger: + def __init__(self): + self.errors = [] + self.infos = [] + + def header(self, msg): + pass + + def step(self, msg): + pass + + def info(self, msg): + self.infos.append(msg) + + def success(self, msg): + pass + + def error(self, msg): + self.errors.append(msg) + + def warning(self, msg): + pass + + def debug(self, msg): + pass + + +def _port(device, vid): + return types.SimpleNamespace(device=device, vid=vid) + + +def _install_fake_pyserial(monkeypatch, comports, serial_cls=None): + """Put a fake `serial` package in sys.modules; pyserial is optional.""" + list_ports = types.ModuleType("serial.tools.list_ports") + list_ports.comports = lambda: comports + tools = types.ModuleType("serial.tools") + tools.list_ports = list_ports + serial = types.ModuleType("serial") + serial.tools = tools + if serial_cls is not None: + serial.Serial = serial_cls + monkeypatch.setitem(sys.modules, "serial", serial) + monkeypatch.setitem(sys.modules, "serial.tools", tools) + monkeypatch.setitem(sys.modules, "serial.tools.list_ports", list_ports) + + +def _remove_pyserial(monkeypatch): + # A None entry in sys.modules makes the import raise ImportError. + for name in ("serial", "serial.tools", "serial.tools.list_ports"): + monkeypatch.setitem(sys.modules, name, None) + + +@pytest.fixture +def no_posix_ports(monkeypatch): + monkeypatch.setattr(commands.glob, "glob", lambda pattern: []) + + +def test_com_ports_keep_only_usb_devices(monkeypatch, no_posix_ports): + _install_fake_pyserial(monkeypatch, [ + _port("COM1", None), # built-in port + _port("COM4", None), # Bluetooth serial port + _port("COM3", 0x10C4), + ]) + assert _serial_ports() == ["COM3"] + + +def test_com_ports_sort_numerically(monkeypatch, no_posix_ports): + _install_fake_pyserial(monkeypatch, [ + _port("COM10", 0x0403), + _port("COM2", 0x0403), + _port("COM3", 0x0403), + ]) + assert _serial_ports() == ["COM2", "COM3", "COM10"] + + +def test_non_com_devices_from_pyserial_are_ignored(monkeypatch): + # On Linux pyserial reports /dev/ttyUSB0 too; the glob already found it. + monkeypatch.setattr( + commands.glob, "glob", + lambda pattern: ["/dev/ttyUSB0"] if pattern == "/dev/ttyUSB*" else [], + ) + _install_fake_pyserial(monkeypatch, [_port("/dev/ttyUSB0", 0x10C4)]) + assert _serial_ports() == ["/dev/ttyUSB0"] + + +def test_posix_ports_found_without_pyserial(monkeypatch): + monkeypatch.setattr( + commands.glob, "glob", + lambda pattern: ["/dev/ttyACM0"] if pattern == "/dev/ttyACM*" else [], + ) + _remove_pyserial(monkeypatch) + assert _serial_ports() == ["/dev/ttyACM0"] + + +def test_monitor_reports_missing_pyserial_before_detection( + monkeypatch, no_posix_ports): + # The before-detection check is Windows-only: on POSIX, glob detection + # does not need pyserial (see the #83 contract tests in + # tests/unit/test_monitor_command.py). + monkeypatch.setattr(sys, "platform", "win32") + _remove_pyserial(monkeypatch) + log = RecordingLogger() + result = CliRunner().invoke(monitor, [], obj=log) + assert result.exit_code == 1 + assert len(log.errors) == 1 + assert "pyserial is not installed" in log.errors[0] + + +def test_monitor_auto_detects_single_usb_com_port( + monkeypatch, no_posix_ports): + opened = [] + + class FakeSerial: + def __init__(self, port, baud, timeout): + opened.append(port) + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def read(self, size): + raise KeyboardInterrupt + + _install_fake_pyserial( + monkeypatch, + [_port("COM1", None), _port("COM5", 0x2341)], + serial_cls=FakeSerial, + ) + log = RecordingLogger() + result = CliRunner().invoke(monitor, [], obj=log) + assert result.exit_code == 0, log.errors + assert opened == ["COM5"] + assert "Auto-detected COM5" in log.infos + + +def test_monitor_refuses_to_guess_between_usb_com_ports( + monkeypatch, no_posix_ports): + _install_fake_pyserial( + monkeypatch, [_port("COM3", 0x10C4), _port("COM7", 0x0403)]) + log = RecordingLogger() + result = CliRunner().invoke(monitor, [], obj=log) + assert result.exit_code == 1 + assert "More than one serial device" in log.errors[0] diff --git a/tests/unit/test_monitor_command.py b/tests/unit/test_monitor_command.py index f729341..2a0b915 100644 --- a/tests/unit/test_monitor_command.py +++ b/tests/unit/test_monitor_command.py @@ -9,6 +9,7 @@ """ import glob +import importlib.util import sys import pytest @@ -39,12 +40,24 @@ def test_monitor_is_a_command(self): class TestMonitorWithoutHardware: + @pytest.mark.skipif( + sys.platform.startswith("win") and importlib.util.find_spec("serial") is None, + reason="on Windows without pyserial, COM ports cannot be enumerated at all, " + "so 'no device present' cannot be established \u2014 the monitor correctly " + "reports the missing pyserial first (covered by test_serial_ports.py)", + ) def test_no_device_fails_cleanly(self, no_serial_devices): result = invoke() assert result.exit_code == 1 assert "No serial device found" in result.output assert "--port" in result.output + @pytest.mark.skipif( + sys.platform.startswith("win") and importlib.util.find_spec("serial") is None, + reason="on Windows without pyserial, COM ports cannot be enumerated at all, " + "so 'no device present' cannot be established \u2014 the monitor correctly " + "reports the missing pyserial first (covered by test_serial_ports.py)", + ) def test_multiple_devices_refuses_to_guess(self, monkeypatch): monkeypatch.setattr( glob, "glob", lambda pattern: ["/dev/ttyUSB0", "/dev/ttyUSB1"]