From aedf723cee4af5ab6ca4ea3543925c20c3dec5f5 Mon Sep 17 00:00:00 2001 From: JP Hutchins Date: Wed, 29 Jul 2026 11:25:42 -0700 Subject: [PATCH] win32: name COM0 through the device namespace Windows resolves only the bare names COM1 through COM9 as MS-DOS device aliases. `serial.tools.list_ports` reports a device whose `PortName` is COM0, but `Serial("COM0")` could not open it: the "\\.\" prefix was applied only when the port number was greater than 8, so the bare name was resolved as an ordinary relative file system path instead. Prefix every COM port that Windows has no alias for, which also covers the zero padded COM00 and COM01 spellings. COM1 through COM9 keep using the MS-DOS device name, so no assumption is made about how well older Windows versions accept a prefixed legacy name. Requiring COM followed by decimal digits preserves the pass through for names such as COMnotanumber without an `int()` call and its ValueError guard. Extracting the rule as `device_path()` makes it testable without a serial port. Fixes #870 Co-Authored-By: claude-opus-5 --- serial/serialwin32.py | 30 +++++++++++------- test/test_serialwin32.py | 66 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 11 deletions(-) create mode 100644 test/test_serialwin32.py diff --git a/serial/serialwin32.py b/serial/serialwin32.py index b8112a52..0c430ceb 100644 --- a/serial/serialwin32.py +++ b/serial/serialwin32.py @@ -13,6 +13,7 @@ # pylint: disable=invalid-name,too-few-public-methods import ctypes +import re import time from serial import win32 @@ -20,6 +21,23 @@ from serial.serialutil import SerialBase, SerialException, to_bytes, PortNotOpenError, SerialTimeoutException +LEGACY_DOS_DEVICE_NAMES = frozenset('COM{}'.format(number) for number in range(1, 10)) +COM_PORT_NAME = re.compile('COM[0-9]+', re.IGNORECASE) + + +def device_path(port): + r"""Return the name that Windows resolves to the given serial port. + + Windows resolves only the bare names COM1 through COM9 as MS-DOS device + aliases. No other serial port has an alias, so COM0 as well as COM10 and + above must be named in the "\\.\COMx" device namespace format; a bare name + would be resolved as an ordinary relative file system path instead. + """ + if COM_PORT_NAME.fullmatch(port) and port.upper() not in LEGACY_DOS_DEVICE_NAMES: + return '\\\\.\\' + port + return port + + class Serial(SerialBase): """Serial port implementation for Win32 based on ctypes.""" @@ -41,18 +59,8 @@ def open(self): raise SerialException("Port must be configured before it can be used.") if self.is_open: raise SerialException("Port is already open.") - # the "\\.\COMx" format is required for devices other than COM1-COM8 - # not all versions of windows seem to support this properly - # so that the first few ports are used with the DOS device name - port = self.name - try: - if port.upper().startswith('COM') and int(port[3:]) > 8: - port = '\\\\.\\' + port - except ValueError: - # for like COMnotanumber - pass self._port_handle = win32.CreateFile( - port, + device_path(self.name), win32.GENERIC_READ | win32.GENERIC_WRITE, 0, # exclusive access None, # no security diff --git a/test/test_serialwin32.py b/test/test_serialwin32.py new file mode 100644 index 00000000..9f232018 --- /dev/null +++ b/test/test_serialwin32.py @@ -0,0 +1,66 @@ +# This file is part of pySerial - Cross platform serial port support for Python +# +# SPDX-License-Identifier: BSD-3-Clause + +""" +Test the Win32 serial port naming. +""" + +import os + +import pytest + +if os.name == "nt": + from serial import serialwin32, win32 + +pytestmark = pytest.mark.skipif(os.name != "nt", reason="Windows only") + + +@pytest.mark.parametrize( + "port, expected", + ( + ("COM1", "COM1"), + ("COM8", "COM8"), + ("COM9", "COM9"), + ("com3", "com3"), + ("COM0", r"\\.\COM0"), + ("com0", r"\\.\com0"), + ("COM00", r"\\.\COM00"), + ("COM01", r"\\.\COM01"), + ("COM10", r"\\.\COM10"), + ("COM255", r"\\.\COM255"), + (r"\\.\COM1", r"\\.\COM1"), + (r"\\.\COM0", r"\\.\COM0"), + ("COM", "COM"), + ("COMnotanumber", "COMnotanumber"), + ("/dev/ttyS0", "/dev/ttyS0"), + ), +) +def test_device_path(port, expected): + """Verify that every port without an MS-DOS device alias is prefixed.""" + + assert serialwin32.device_path(port) == expected + + +@pytest.mark.parametrize( + "port, expected", + ( + pytest.param("COM0", r"\\.\COM0", id="device-namespace"), + pytest.param("COM1", "COM1", id="ms-dos-alias"), + ), +) +def test_open_names_the_device(monkeypatch, port, expected): + """Verify that `open()` hands the resolvable name to CreateFile.""" + + names = [] + + def create_file(name, *args): + names.append(name) + return win32.INVALID_HANDLE_VALUE + + monkeypatch.setattr(win32, "CreateFile", create_file) + + with pytest.raises(serialwin32.SerialException): + serialwin32.Serial(port) + + assert names == [expected]