From 814fcb5788b8b830cd89bf83aa72808e4aeb835d Mon Sep 17 00:00:00 2001 From: Ali Hamdan Date: Sat, 31 Dec 2022 17:24:14 +0100 Subject: [PATCH] First commit --- .flake8 | 6 + .gitignore | 8 + .pre-commit-config.yaml | 43 ++++ .vscode/cspell.json | 47 ++++ .vscode/settings.json | 12 + LICENSE | 57 +++++ README.md | 25 ++ lsports/__init__.py | 17 ++ lsports/__main__.py | 69 ++++++ lsports/_common.py | 120 ++++++++++ lsports/_linux.py | 102 +++++++++ lsports/_osx.py | 282 +++++++++++++++++++++++ lsports/_posix.py | 86 +++++++ lsports/_windows.py | 492 ++++++++++++++++++++++++++++++++++++++++ lsports/py.typed | 0 pyproject.toml | 64 ++++++ tests/__init__.py | 0 tests/test_comports.py | 26 +++ 18 files changed, 1456 insertions(+) create mode 100644 .flake8 create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 .vscode/cspell.json create mode 100644 .vscode/settings.json create mode 100644 LICENSE create mode 100644 README.md create mode 100644 lsports/__init__.py create mode 100644 lsports/__main__.py create mode 100644 lsports/_common.py create mode 100644 lsports/_linux.py create mode 100644 lsports/_osx.py create mode 100644 lsports/_posix.py create mode 100644 lsports/_windows.py create mode 100644 lsports/py.typed create mode 100644 pyproject.toml create mode 100644 tests/__init__.py create mode 100644 tests/test_comports.py diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..912d9b5 --- /dev/null +++ b/.flake8 @@ -0,0 +1,6 @@ +[flake8] +max_line_length = 100 +extend-select = B950 +extend-ignore = E203, E501 +per-file-ignores = + lsports/_windows.py: E221, E402 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b25f7c1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.coverage +.tox/ +*.egg-info/ +*.pyc +htmlcov/ +venv/ +build/ +dist/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..f5fa924 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,43 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: check-added-large-files + - id: check-builtin-literals + - id: check-case-conflict + - id: check-docstring-first + - id: check-merge-conflict + - id: check-toml + - id: check-yaml + - id: debug-statements + - id: end-of-file-fixer + - id: mixed-line-ending + args: [--fix=lf] + - id: trailing-whitespace + - repo: https://github.com/asottile/pyupgrade + rev: v3.3.1 + hooks: + - id: pyupgrade + args: [--py37-plus] + - repo: https://github.com/pycqa/isort + rev: 5.11.4 + hooks: + - id: isort + args: [--add-import, "from __future__ import annotations"] + - repo: https://github.com/psf/black + rev: 22.12.0 + hooks: + - id: black + - repo: https://github.com/asottile/yesqa + rev: v1.4.0 + hooks: + - id: yesqa + - repo: https://github.com/pycqa/flake8 + rev: 6.0.0 + hooks: + - id: flake8 + additional_dependencies: [ "flake8-bugbear" ] + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v0.991 + hooks: + - id: mypy diff --git a/.vscode/cspell.json b/.vscode/cspell.json new file mode 100644 index 0000000..282a3c9 --- /dev/null +++ b/.vscode/cspell.json @@ -0,0 +1,47 @@ +{ + // Version of the setting file. Always 0.2 + "version": "0.2", + // language - current active spelling language + "language": "en", + // words - list of words to be always considered correct + "words": [ + "amba", + "cdrom", + "cfgmgr", + "devinst", + "devpkey", + "devprop", + "devpropguid", + "devpropid", + "devpropkey", + "devs", + "dics", + "digcf", + "direg", + "fmtid", + "ftdi", + "ftdibus", + "guids", + "hardwareid", + "hdevinfo", + "hwid", + "iokit", + "iousb", + "liechti", + "lpctstr", + "lsports", + "pguid", + "ptstr", + "raspi", + "spdrp", + "sysfs", + "wparam" + ], + // flagWords - list of words to be always considered incorrect + // This is useful for offensive words and common spelling errors. + // For example "hte" should be "the" + "flagWords": [ + "hte", + "tge" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..29ef66e --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,12 @@ +{ + "python.formatting.provider": "none", + "python.testing.pytestEnabled": true, + "python.testing.unittestEnabled": false, + "[python]": { + "editor.defaultFormatter": "ms-python.black-formatter", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.organizeImports": true + } + }, +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..65f10a6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,57 @@ +MIT License + +Copyright (c) 2022 Ali Hamdan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +------------------------------------------------------------------------------- +Original source: pySerial https://github.com/pyserial/pyserial + +Copyright (c) 2001-2020 Chris Liechti +All Rights Reserved. + + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5a06a67 --- /dev/null +++ b/README.md @@ -0,0 +1,25 @@ +# lsports + +A simple Python 3.7+ module to list serial ports on Windows, Linux, and macOS. + +This is a modified version of `serial.tools.list_ports` from +[pySerial](https://github.com/pyserial/pyserial) by Chris Liechti. + +## Installation + +```bash +pip install lsports +``` + +## Usage + +The module provides a single function `comports` that returns a list of `PortInfo` objects. +Each `PortInfo` object contains information about a connected serial port. +```python +from lsports import comports + +for port in comports(): + print(port.device, port.product, port.hwid) +``` +For a full list of available attributes, see the `PortInfo` class. Only `comports` and `PortInfo` +are considered public API. diff --git a/lsports/__init__.py b/lsports/__init__.py new file mode 100644 index 0000000..ec2bec1 --- /dev/null +++ b/lsports/__init__.py @@ -0,0 +1,17 @@ +# Originally sourced from pySerial. https://github.com/pyserial/pyserial +# (C) 2011-2015 Chris Liechti +# SPDX-License-Identifier: BSD-3-Clause +from __future__ import annotations + +import os + +from lsports._common import PortInfo as PortInfo + +__all__ = ["comports", "PortInfo"] + +if os.name == "nt": + from lsports._windows import comports as comports # type: ignore[attr-defined] +elif os.name == "posix": + from lsports._posix import comports as comports +else: + raise ImportError(f"No implementation available for '{os.name}' platforms.") diff --git a/lsports/__main__.py b/lsports/__main__.py new file mode 100644 index 0000000..879423f --- /dev/null +++ b/lsports/__main__.py @@ -0,0 +1,69 @@ +# Originally sourced from pySerial. https://github.com/pyserial/pyserial +# (C) 2011-2015 Chris Liechti +# SPDX-License-Identifier: BSD-3-Clause +from __future__ import annotations + +import argparse +import re +import sys +from collections.abc import Generator + +from lsports import PortInfo, comports + + +def grep( + regexp: str | re.Pattern[str], include_links: bool = False +) -> Generator[PortInfo, None, None]: + """Search for ports using a regular expression. + + Port name, description and hardware ID are searched. + + Returns: + An iterable that returns the same tuples as :func:`comport` would do. + """ + r = re.compile(regexp, re.I) + for info in comports(include_links): + if r.search(info.device) or r.search(info.description) or r.search(info.hwid): + yield info + + +def main() -> int: + parser = argparse.ArgumentParser(prog="lsports", description="Serial ports enumeration.") + parser.add_argument("regexp", nargs="?", help="Only show ports that match this regex") + verbosity = parser.add_mutually_exclusive_group() + verbosity.add_argument("-v", "--verbose", action="store_true", help="Show more messages") + verbosity.add_argument("-q", "--quiet", action="store_true", help="Suppress all messages") + parser.add_argument("-n", type=int, help="Only output the N-th entry") + parser.add_argument( + "-s", + "--include-links", + action="store_true", + help="Include entries that are symlinks to real devices", + ) + args = parser.parse_args() + + hits = 0 + # get iterator w/ or w/o filter + if args.regexp: + if not args.quiet: + print(f"Filtered list with regexp: {args.regexp!r}", file=sys.stderr) + devices = sorted(grep(args.regexp, include_links=args.include_links)) + else: + devices = sorted(comports(include_links=args.include_links)) + # list them + for n, device in enumerate(devices, 1): + if args.n is None or args.n == n: + print(f"{device.device}") + if args.verbose: + msg = f" desc: {device.description}\n hwid: {device.hwid}" + if device.product: + msg += f"\n prod: {device.product}" + print(msg) + hits += 1 + if not args.quiet: + print({0: "No ports", 1: "1 port"}.get(hits, f"{hits} ports") + " found", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/lsports/_common.py b/lsports/_common.py new file mode 100644 index 0000000..1c5b514 --- /dev/null +++ b/lsports/_common.py @@ -0,0 +1,120 @@ +# Originally sourced from pySerial. https://github.com/pyserial/pyserial +# (C) 2011-2015 Chris Liechti +# SPDX-License-Identifier: BSD-3-Clause +from __future__ import annotations + +import glob +import os +import re +from collections.abc import Container + + +def _numsplit(text: str) -> list[str | int]: + """Convert string into a list of texts and numbers in order to support a natural sorting.""" + result = [] + for group in re.split(r"(\d+)", text): + if group: + try: + group = int(group) + except ValueError: + pass + result.append(group) + return result + + +class PortInfo: + """Serial port information. + + General attributes: + - device (str): device name, e.g. /dev/ttyUSB0 + - name (str): device base name, e.g. ttyUSB0 + - description (str): human readable description of the device + - hwid (str): hardware ID, e.g. VID:PID=0403:6001 SER=123456 LOCATION=1-1.2 + + USB specific attributes: + - vid (int or None): USB vendor ID + - pid (int or None): USB product ID + - serial_number (str or None): USB serial number + - location (str or None): USB location + - manufacturer (str or None): USB manufacturer + - product (str or None): USB product name (Bus Reported Device Description on Windows) + - interface (str or None): USB interface name + + Linux specific attributes (SysFS): + - usb_device_path (str or None): USB device path + - device_path (str or None): device path + - subsystem (str or None): device subsystem + - usb_interface_path (str or None): USB interface path + """ + + def __init__(self, device: str, skip_link_detection: bool = False) -> None: + self.device = device + self.name = os.path.basename(device) + self.description = "n/a" + self.hwid = "n/a" + # USB specific data + self.vid: int | None = None + self.pid: int | None = None + self.serial_number: str | None = None + self.location: str | None = None + self.manufacturer: str | None = None + self.product: str | None = None + self.interface: str | None = None + # Linux specific data + self.usb_device_path: str | None = None + self.device_path: str | None = None + self.subsystem: str | None = None + self.usb_interface_path: str | None = None + # Special handling for links + if not skip_link_detection and device is not None and os.path.islink(device): + self.hwid = f"LINK={os.path.realpath(device)}" + + def __repr__(self) -> str: + return f"{type(self).__name__}({self.device!r})" + + def __str__(self) -> str: + return f"{self.device} - {self.description}" + + def __eq__(self, other: object) -> bool: + if not isinstance(other, PortInfo): + return NotImplemented + return self.device == other.device + + def __hash__(self) -> int: + return hash(self.device) + + def __lt__(self, other: PortInfo) -> bool: + if not isinstance(other, PortInfo): + return NotImplemented + return _numsplit(self.device) < _numsplit(other.device) + + def usb_description(self) -> str: + """A short string to name the port based on USB info.""" + if self.interface is not None: + return f"{self.product} - {self.interface}" + elif self.product is not None: + return self.product + else: + return self.name + + def usb_info(self) -> str: + """A string with USB related information about device.""" + vid = self.vid or 0 + pid = self.pid or 0 + ser = f" SER={self.serial_number}" if self.serial_number else "" + loc = f" LOCATION={self.location}" if self.location else "" + return f"USB VID:PID={vid:04X}:{pid:04X}{ser}{loc}" + + def apply_usb_info(self) -> None: + """Update description and hwid from USB data.""" + self.description = self.usb_description() + self.hwid = self.usb_info() + + +def list_links(devices: Container[str]) -> list[str]: + """Search all /dev devices and look for symlinks to known ports already listed in devices.""" + links: list[str] = [] + for device in glob.glob("/dev/*"): + if os.path.islink(device) and os.path.realpath(device) in devices: + links.append(device) + return links diff --git a/lsports/_linux.py b/lsports/_linux.py new file mode 100644 index 0000000..d113c7e --- /dev/null +++ b/lsports/_linux.py @@ -0,0 +1,102 @@ +# Originally sourced from pySerial. https://github.com/pyserial/pyserial +# (C) 2011-2015 Chris Liechti +# SPDX-License-Identifier: BSD-3-Clause +from __future__ import annotations + +import glob +import os + +from lsports._common import PortInfo, list_links + + +def _readline(*args: str) -> str: + with open(os.path.join(*args)) as f: + return f.readline().strip() + + +def _readline_optional(*args: str) -> str | None: + try: + return _readline(*args) + except OSError: + return None + + +def sysfs_wrapper(device: str) -> PortInfo: + """Wrapper for easy sysfs access and device info.""" + info = PortInfo(device) + # Special handling for links + if device is not None and os.path.islink(device): + device = os.path.realpath(device) + is_link = True + else: + is_link = False + info.usb_device_path = None + if os.path.exists(f"/sys/class/tty/{info.name}/device"): + info.device_path = os.path.realpath(f"/sys/class/tty/{info.name}/device") + info.subsystem = os.path.basename( + os.path.realpath(os.path.join(info.device_path, "subsystem")) + ) + else: + info.device_path = None + info.subsystem = None + # Check device type + if info.subsystem == "usb-serial": + assert info.device_path is not None + info.usb_interface_path = os.path.dirname(info.device_path) + elif info.subsystem == "usb": + info.usb_interface_path = info.device_path + else: + info.usb_interface_path = None + # Fill-in info for USB devices + if info.usb_interface_path is not None: + info.usb_device_path = os.path.dirname(info.usb_interface_path) + num_if = int(_readline_optional(info.usb_device_path, "bNumInterfaces") or "1") + info.vid = int(_readline(info.usb_device_path, "idVendor"), 16) + info.pid = int(_readline(info.usb_device_path, "idProduct"), 16) + info.serial_number = _readline_optional(info.usb_device_path, "serial") + info.location = os.path.basename( # num_if > 1 for multi interface devices like FT4232 + info.usb_interface_path if num_if > 1 else info.usb_device_path + ) + info.manufacturer = _readline_optional(info.usb_device_path, "manufacturer") + info.product = _readline_optional(info.usb_device_path, "product") + info.interface = _readline_optional(info.usb_interface_path, "interface") + + if info.subsystem in ("usb", "usb-serial"): + info.apply_usb_info() + elif info.subsystem == "pnp": # PCI based devices + info.description = info.name + assert info.device_path is not None + info.hwid = _readline(info.device_path, "id") + elif info.subsystem == "amba": # raspi + info.description = info.name + assert info.device_path is not None + info.hwid = os.path.basename(info.device_path) + + if is_link: + info.hwid += f" LINK={device}" + return info + + +def comports(include_links: bool = False) -> list[PortInfo]: + devices = set() + # built-in serial ports + devices.update(glob.glob("/dev/ttyS*")) + # usb-serial with own driver + devices.update(glob.glob("/dev/ttyUSB*")) + # xr-usb-serial port exar (DELL Edge 3001) + devices.update(glob.glob("/dev/ttyXRUSB*")) + # usb-serial with CDC-ACM profile + devices.update(glob.glob("/dev/ttyACM*")) + # ARM internal port (raspi) + devices.update(glob.glob("/dev/ttyAMA*")) + # BT serial devices + devices.update(glob.glob("/dev/rfcomm*")) + # Advantech multi-port serial controllers + devices.update(glob.glob("/dev/ttyAP*")) + # https://www.kernel.org/doc/Documentation/usb/gadget_serial.txt + devices.update(glob.glob("/dev/ttyGS*")) + + if include_links: + devices.update(list_links(devices)) + # hide non-present internal serial ports + return [info for info in (sysfs_wrapper(d) for d in devices) if info.subsystem != "platform"] diff --git a/lsports/_osx.py b/lsports/_osx.py new file mode 100644 index 0000000..fac8260 --- /dev/null +++ b/lsports/_osx.py @@ -0,0 +1,282 @@ +# Originally sourced from pySerial. https://github.com/pyserial/pyserial +# (C) 2011-2015 Chris Liechti +# SPDX-License-Identifier: BSD-3-Clause +from __future__ import annotations + +import ctypes +from ctypes import byref, create_string_buffer + +from lsports._common import PortInfo + +# List all of the callout devices in OS/X by querying IOKit. +# See the following for a reference of how to do this: +# http://developer.apple.com/library/mac/#documentation/DeviceDrivers/Conceptual/WorkingWSerial/WWSerial_SerialDevs/SerialDevices.html#//apple_ref/doc/uid/TP30000384-CIHGEAFD + +# More help from darwin_hid.py +# Also see the 'IORegistryExplorer' for an idea of what we are actually searching + +kCFStringEncodingMacRoman = 0 +kCFStringEncodingUTF8 = 0x08000100 + +# defined in `IOKit/usb/USBSpec.h` +kUSBVendorString = b"USB Vendor Name" +kUSBSerialNumberString = b"USB Serial Number" + +# `io_name_t` defined as `typedef char io_name_t[128];` in `device/device_types.h` +io_name_size = 128 + +# defined in `mach/kern_return.h` +KERN_SUCCESS = 0 +# kern_return_t defined as `typedef int kern_return_t;` in `mach/i386/kern_return.h` +kern_return_t = ctypes.c_int + +iokit = ctypes.cdll.LoadLibrary("/System/Library/Frameworks/IOKit.framework/IOKit") +cf = ctypes.cdll.LoadLibrary("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation") + +# kIOMasterPortDefault is no longer exported in BigSur but no biggie, using NULL works just the same +kIOMasterPortDefault = 0 # WAS: ctypes.c_void_p.in_dll(iokit, "kIOMasterPortDefault") +kCFAllocatorDefault = ctypes.c_void_p.in_dll(cf, "kCFAllocatorDefault") + +iokit.IOServiceMatching.restype = ctypes.c_void_p + +iokit.IOServiceGetMatchingServices.argtypes = (ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p) +iokit.IOServiceGetMatchingServices.restype = kern_return_t + +iokit.IORegistryEntryGetParentEntry.argtypes = (ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p) +iokit.IOServiceGetMatchingServices.restype = kern_return_t + +iokit.IORegistryEntryCreateCFProperty.argtypes = ( + ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_uint32 # fmt: skip +) +iokit.IORegistryEntryCreateCFProperty.restype = ctypes.c_void_p + +iokit.IORegistryEntryGetPath.argtypes = (ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p) +iokit.IORegistryEntryGetPath.restype = kern_return_t + +iokit.IORegistryEntryGetName.argtypes = (ctypes.c_void_p, ctypes.c_void_p) +iokit.IORegistryEntryGetName.restype = kern_return_t + +iokit.IOObjectGetClass.argtypes = (ctypes.c_void_p, ctypes.c_void_p) +iokit.IOObjectGetClass.restype = kern_return_t + +iokit.IOObjectRelease.argtypes = (ctypes.c_void_p,) + + +cf.CFStringCreateWithCString.argtypes = (ctypes.c_void_p, ctypes.c_char_p, ctypes.c_int32) +cf.CFStringCreateWithCString.restype = ctypes.c_void_p + +cf.CFStringGetCStringPtr.argtypes = (ctypes.c_void_p, ctypes.c_uint32) +cf.CFStringGetCStringPtr.restype = ctypes.c_char_p + +cf.CFStringGetCString.argtypes = (ctypes.c_void_p, ctypes.c_void_p, ctypes.c_long, ctypes.c_uint32) +cf.CFStringGetCString.restype = ctypes.c_bool + +cf.CFNumberGetValue.argtypes = (ctypes.c_void_p, ctypes.c_uint32, ctypes.c_void_p) +cf.CFNumberGetValue.restype = ctypes.c_void_p + +# void CFRelease ( CFTypeRef cf ); +cf.CFRelease.argtypes = (ctypes.c_void_p,) +cf.CFRelease.restype = None + +# CFNumber type defines +kCFNumberSInt8Type = 1 +kCFNumberSInt16Type = 2 +kCFNumberSInt32Type = 3 +kCFNumberSInt64Type = 4 + + +def get_string_property(device_type: ctypes._CData, property: bytes) -> str | None: + """Search the given device for the specified string property. + + Args: + device_type: + Type of Device. + + property: + Bytestring to search for. + + Returns: + Python string containing the value, or None if not found. + """ + key = cf.CFStringCreateWithCString(kCFAllocatorDefault, property, kCFStringEncodingUTF8) + CFContainer = iokit.IORegistryEntryCreateCFProperty(device_type, key, kCFAllocatorDefault, 0) + output = None + if CFContainer: + output = cf.CFStringGetCStringPtr(CFContainer, 0) + if output is not None: + output = output.decode("utf-8") + else: + buffer = create_string_buffer(io_name_size) + success = cf.CFStringGetCString( + CFContainer, byref(buffer), io_name_size, kCFStringEncodingUTF8 + ) + if success: + output = buffer.value.decode("utf-8") + cf.CFRelease(CFContainer) + return output + + +def get_int_property(device_type: ctypes._CData, property: bytes, cf_type: int) -> int | None: + """Search the given device for the specified string property. + + Args: + device_type: + Device to search. + + property: + Bytestring to search for. + + cf_type: + CFType number. + + Returns: + Python int containing the value, or None if not found. + """ + key = cf.CFStringCreateWithCString(kCFAllocatorDefault, property, kCFStringEncodingUTF8) + CFContainer = iokit.IORegistryEntryCreateCFProperty(device_type, key, kCFAllocatorDefault, 0) + if CFContainer: + number: ctypes.c_uint32 | ctypes.c_uint16 + if cf_type == kCFNumberSInt32Type: + number = ctypes.c_uint32() + elif cf_type == kCFNumberSInt16Type: + number = ctypes.c_uint16() + else: + raise NotImplementedError(f"CFType {cf_type} not implemented") + cf.CFNumberGetValue(CFContainer, cf_type, byref(number)) + cf.CFRelease(CFContainer) + return number.value + return None + + +def IORegistryEntryGetName(device: ctypes._CData) -> str | None: + devicename = create_string_buffer(io_name_size) + res = iokit.IORegistryEntryGetName(device, byref(devicename)) + if res != KERN_SUCCESS: + return None + # I don't know if this encoding is guaranteed. It may be dependent on system locale. + return devicename.value.decode("utf-8") + + +def IOObjectGetClass(device: ctypes._CData) -> bytes: + classname = create_string_buffer(io_name_size) + iokit.IOObjectGetClass(device, byref(classname)) + return classname.value + + +def GetParentDeviceByType(device: ctypes._CData, parent_type: bytes) -> ctypes._CData | None: + """Find the first parent of a device that implements the ``parent_type``. + + Args: + device: + IOService Service to inspect. + + parent_type: + Type of parent to find. + + Returns: + Pointer to the parent type, or None if it was not found. + """ + # First, try to walk up the IOService tree to find a parent of this device that is a IOUSBDevice. + while IOObjectGetClass(device) != parent_type: + parent = ctypes.c_void_p() + response = iokit.IORegistryEntryGetParentEntry(device, b"IOService", byref(parent)) + # If we weren't able to find a parent for the device, we're done. + if response != KERN_SUCCESS: + return None + device = parent + return device + + +def GetIOServicesByType(service_type: bytes) -> list[ctypes._CData]: + """List specified ``service_type``.""" + serial_port_iterator = ctypes.c_void_p() + iokit.IOServiceGetMatchingServices( + kIOMasterPortDefault, iokit.IOServiceMatching(service_type), byref(serial_port_iterator) + ) + services = [] + while iokit.IOIteratorIsValid(serial_port_iterator): + service = iokit.IOIteratorNext(serial_port_iterator) + if not service: + break + services.append(service) + iokit.IOObjectRelease(serial_port_iterator) + return services + + +def location_to_string(locationID: int) -> str: + """Helper to calculate port and bus number from locationID.""" + loc = [f"{locationID >> 24}-"] + while locationID & 0xF00000: + if len(loc) > 1: + loc.append(".") + loc.append(f"{(locationID >> 20) & 0xf}") + locationID <<= 4 + return "".join(loc) + + +class SuitableSerialInterface: + def __init__(self, id: int | None, name: str | None) -> None: + self.id = id + self.name = name + + +def scan_interfaces() -> list[SuitableSerialInterface]: + """Helper function to scan USB interfaces. + + Returns: + A list of SuitableSerialInterface objects with name and id attributes. + """ + interfaces = [] + for service in GetIOServicesByType(b"IOSerialBSDClient"): + device = get_string_property(service, b"IOCalloutDevice") + if device: + usb_device = GetParentDeviceByType(service, b"IOUSBInterface") + if usb_device: + name = get_string_property(usb_device, b"USB Interface Name") or None + locationID = ( + get_int_property(usb_device, b"locationID", kCFNumberSInt32Type) or None + ) + interfaces.append(SuitableSerialInterface(locationID, name)) + return interfaces + + +def search_for_locationID_in_interfaces( + serial_interfaces: list[SuitableSerialInterface], locationID: int +) -> str | None: + for interface in serial_interfaces: + if interface.id == locationID: + return interface.name + return None + + +def comports(include_links: bool = False) -> list[PortInfo]: + # include_links is currently ignored. Are links in /dev even supported here? + # Scan for all iokit serial ports + services = GetIOServicesByType(b"IOSerialBSDClient") + ports = [] + serial_interfaces = scan_interfaces() + for service in services: + # First, add the callout device file. + device = get_string_property(service, b"IOCalloutDevice") + if device: + info = PortInfo(device) + usb_device = GetParentDeviceByType(service, b"IOUSBHostDevice") + if not usb_device: + # If the serial port is implemented by IOUSBDevice (deprecated as of 10.11) + usb_device = GetParentDeviceByType(service, b"IOUSBDevice") + if usb_device: + # Fetch some useful information from properties + info.vid = get_int_property(usb_device, b"idVendor", kCFNumberSInt16Type) + info.pid = get_int_property(usb_device, b"idProduct", kCFNumberSInt16Type) + info.serial_number = get_string_property(usb_device, kUSBSerialNumberString) + # We know this is a usb device, so the IORegistryEntryName should always be aliased + # to the usb product name string descriptor. + info.product = IORegistryEntryGetName(usb_device) or "n/a" + info.manufacturer = get_string_property(usb_device, kUSBVendorString) + locationID = get_int_property(usb_device, b"locationID", kCFNumberSInt32Type) + assert locationID is not None + info.location = location_to_string(locationID) + info.interface = search_for_locationID_in_interfaces(serial_interfaces, locationID) + info.apply_usb_info() + ports.append(info) + return ports diff --git a/lsports/_posix.py b/lsports/_posix.py new file mode 100644 index 0000000..4b8b2a5 --- /dev/null +++ b/lsports/_posix.py @@ -0,0 +1,86 @@ +# Originally sourced from pySerial. https://github.com/pyserial/pyserial +# (C) 2011-2015 Chris Liechti +# SPDX-License-Identifier: BSD-3-Clause +from __future__ import annotations + +import glob +import sys + +from lsports._common import PortInfo, list_links + +if sys.platform == "linux": # Linux + from lsports._linux import comports as comports + +elif sys.platform == "darwin": # OS X + from lsports._osx import comports as comports + +elif sys.platform == "cygwin": # Cygwin/win32 + # cygwin accepts /dev/com* in many contexts + # (such as 'open' call, explicit 'ls'), but 'glob.glob' + # and bare 'ls' do not; so use /dev/ttyS* instead + def comports(include_links: bool = False) -> list[PortInfo]: + devices = set(glob.glob("/dev/ttyS*")) + if include_links: + devices.update(list_links(devices)) + return [PortInfo(d) for d in devices] + +elif sys.platform.startswith("openbsd"): # OpenBSD + + def comports(include_links: bool = False) -> list[PortInfo]: + devices = set(glob.glob("/dev/cua*")) + if include_links: + devices.update(list_links(devices)) + return [PortInfo(d) for d in devices] + +elif sys.platform.startswith(("bsd", "freebsd")): + + def comports(include_links: bool = False) -> list[PortInfo]: + devices = set(glob.glob("/dev/cua*[!.init][!.lock]")) + if include_links: + devices.update(list_links(devices)) + return [PortInfo(d) for d in devices] + +elif sys.platform.startswith("netbsd"): # NetBSD + + def comports(include_links: bool = False) -> list[PortInfo]: + devices = set(glob.glob("/dev/dty*")) + if include_links: + devices.update(list_links(devices)) + return [PortInfo(d) for d in devices] + +elif sys.platform.startswith("irix"): # IRIX + + def comports(include_links: bool = False) -> list[PortInfo]: + devices = set(glob.glob("/dev/ttyf*")) + if include_links: + devices.update(list_links(devices)) + return [PortInfo(d) for d in devices] + +elif sys.platform.startswith("hp"): # HP-UX (not tested) + + def comports(include_links: bool = False) -> list[PortInfo]: + devices = set(glob.glob("/dev/tty*p0")) + if include_links: + devices.update(list_links(devices)) + return [PortInfo(d) for d in devices] + +elif sys.platform.startswith("sunos"): # Solaris/SunOS + + def comports(include_links: bool = False) -> list[PortInfo]: + devices = set(glob.glob("/dev/tty*c")) + if include_links: + devices.update(list_links(devices)) + return [PortInfo(d) for d in devices] + +elif sys.platform.startswith("aix"): # AIX + + def comports(include_links: bool = False) -> list[PortInfo]: + devices = set(glob.glob("/dev/tty*")) + if include_links: + devices.update(list_links(devices)) + return [PortInfo(d) for d in devices] + +else: + raise ImportError(f"No implementation available for '{sys.platform}' platforms.") + +comports.__doc__ = "Scan for available ports." diff --git a/lsports/_windows.py b/lsports/_windows.py new file mode 100644 index 0000000..852dd23 --- /dev/null +++ b/lsports/_windows.py @@ -0,0 +1,492 @@ +# Originally sourced from pySerial. https://github.com/pyserial/pyserial +# (C) 2011-2015 Chris Liechti +# SPDX-License-Identifier: BSD-3-Clause +from __future__ import annotations + +import sys + +assert sys.platform == "win32", "This module is only for Windows." + +import ctypes +import re +from collections.abc import Generator +from ctypes import GetLastError, WinError, byref, create_unicode_buffer, sizeof +from ctypes.wintypes import ( + BOOL, + DWORD, + HKEY, + HWND, + LONG, + LPDWORD, + PDWORD, + PULONG, + ULONG, + WORD, + WPARAM, +) +from winreg import KEY_READ, REG_DWORD + +from lsports._common import PortInfo + +HDEVINFO = ctypes.c_void_p +LPCTSTR = ctypes.c_wchar_p +PCTSTR = ctypes.c_wchar_p +PTSTR = ctypes.c_wchar_p +LPBYTE = PBYTE = ctypes.c_void_p +UBYTE = ctypes.c_ubyte +ULONG_PTR = WPARAM + +ACCESS_MASK = DWORD +REGSAM = ACCESS_MASK + +# fmt: off +DIGCF_DEFAULT = 0x00000001 +DIGCF_PRESENT = 0x00000002 +DIGCF_ALLCLASSES = 0x00000004 +DIGCF_PROFILE = 0x00000008 +DIGCF_DEVICEINTERFACE = 0x00000010 + +ERROR_INVALID_DATA = 0x0000000D +ERROR_INSUFFICIENT_BUFFER = 0x0000007A +ERROR_NOT_FOUND = 0x00000490 + +SPDRP_DEVICEDESC = 0x00000000 +SPDRP_HARDWAREID = 0x00000001 +SPDRP_CLASS = 0x00000007 +SPDRP_MFG = 0x0000000B +SPDRP_FRIENDLYNAME = 0x0000000C +SPDRP_CAPABILITIES = 0x0000000F +SPDRP_BUSNUMBER = 0x00000015 +SPDRP_DEVTYPE = 0x00000019 +SPDRP_EXCLUSIVE = 0x0000001A +SPDRP_CHARACTERISTICS = 0x0000001B +SPDRP_ADDRESS = 0x0000001C +SPDRP_UI_NUMBER_DESC_FORMAT = 0x0000001D +SPDRP_INSTALL_STATE = 0x00000022 +SPDRP_LOCATION_PATHS = 0x00000023 + +INVALID_HANDLE_VALUE = 0x00000000 +DICS_FLAG_GLOBAL = 0x00000001 +DIREG_DEV = 0x00000001 +# fmt: on + +MAX_USB_DEVICE_TREE_TRAVERSAL_DEPTH = 5 +USB_HW_ID_PAT = re.compile(r"VID_([0-9a-f]{4})(&PID_([0-9a-f]{4}))?(&MI_(\d{2}))?(\\(.*))?", re.I) + + +# https://learn.microsoft.com/en-us/windows/win32/api/guiddef/ns-guiddef-guid +class GUID(ctypes.Structure): + _fields_ = [ + # Data1: Specifies the first 8 hexadecimal digits of the GUID. + ("Data1", DWORD), + # Data2: Specifies the first group of 4 hexadecimal digits. + ("Data2", WORD), + # Data3: Specifies the second group of 4 hexadecimal digits. + ("Data3", WORD), + # Data4: Array of 8 bytes. The first 2 bytes contain the third group of 4 hexadecimal + # digits. The remaining 6 bytes contain the final 12 hexadecimal digits. + ("Data4", UBYTE * 8), + ] + + def __str__(self) -> str: + return ( + f"{{{self.Data1:08x}-{self.Data2:04x}-{self.Data3:04x}-" + f"{bytes(self.Data4[:2]).hex()}-{bytes(self.Data4[2:]).hex()}}}" + ) + + +PGUID = ctypes.POINTER(GUID) + + +# https://learn.microsoft.com/en-us/windows/win32/api/setupapi/ns-setupapi-sp_devinfo_data +class SP_DEVINFO_DATA(ctypes.Structure): + _fields_ = [ + # cbSize: The size, in bytes, of the SP_DEVINFO_DATA structure. + ("cbSize", DWORD), + # ClassGuid: The GUID of the device's setup class. + ("ClassGuid", GUID), + # DevInst: An opaque handle to the device instance (also known as a handle to the devnode). + # Some functions, such as SetupDiXxx functions, take the whole SP_DEVINFO_DATA structure as + # input to identify a device in a device information set. Other functions, such as CM_Xxx + # functions like CM_Get_DevNode_Status, take this DevInst handle as input. + ("DevInst", DWORD), + # Reserved: For internal use only. + ("Reserved", ULONG_PTR), + ] + + def __str__(self) -> str: + return f"ClassGuid:{self.ClassGuid} DevInst:{self.DevInst}" + + +PSP_DEVINFO_DATA = ctypes.POINTER(SP_DEVINFO_DATA) + + +# https://learn.microsoft.com/en-us/windows-hardware/drivers/install/property-keys +class SP_DEVPROPKEY(ctypes.Structure): + _fields_ = [ + # fmtid: A DEVPROPGUID-typed value that specifies a property category. + ("fmtid", GUID), + # pid: A DEVPROPID-typed value that uniquely identifies the property within the property + # category. For internal system reasons, a property identifier must be greater than or + # equal to two. + ("pid", ULONG), + ] + + def __str__(self) -> str: + return f"fmtid:{self.fmtid} pid:{self.pid}" + + +# https://learn.microsoft.com/en-us/windows-hardware/drivers/install/devpkey-device-busreporteddevicedesc +DEVPKEY_Device_BusReportedDeviceDesc = SP_DEVPROPKEY( + # https://learn.microsoft.com/en-us/windows/win32/properties/devices-bumper + # https://www.magnumdb.com/search?q=DEVPKEY_Device_BusReportedDeviceDesc + # Get the GUID with `SetupDiClassGuidsFromName("CDROM", ...)` + GUID( + 0x540B947E, + 0x8B40, + 0x45BC, + (0xA8, 0xA2, 0x6A, 0x0B, 0x89, 0x4C, 0xBD, 0xA2), + ), + REG_DWORD, +) + +PSP_DEVPROPKEY = ctypes.POINTER(SP_DEVPROPKEY) + +PSP_DEVICE_INTERFACE_DETAIL_DATA = ctypes.c_void_p + +setupapi = ctypes.windll.LoadLibrary("setupapi") +SetupDiDestroyDeviceInfoList = setupapi.SetupDiDestroyDeviceInfoList +SetupDiDestroyDeviceInfoList.argtypes = (HDEVINFO,) +SetupDiDestroyDeviceInfoList.restype = BOOL + +SetupDiClassGuidsFromName = setupapi.SetupDiClassGuidsFromNameW +SetupDiClassGuidsFromName.argtypes = (PCTSTR, PGUID, DWORD, PDWORD) +SetupDiClassGuidsFromName.restype = BOOL + +SetupDiEnumDeviceInfo = setupapi.SetupDiEnumDeviceInfo +SetupDiEnumDeviceInfo.argtypes = (HDEVINFO, DWORD, PSP_DEVINFO_DATA) +SetupDiEnumDeviceInfo.restype = BOOL + +SetupDiGetClassDevs = setupapi.SetupDiGetClassDevsW +SetupDiGetClassDevs.argtypes = (PGUID, PCTSTR, HWND, DWORD) +SetupDiGetClassDevs.restype = HDEVINFO + +SetupDiGetDeviceRegistryProperty = setupapi.SetupDiGetDeviceRegistryPropertyW +SetupDiGetDeviceRegistryProperty.argtypes = ( + HDEVINFO, + PSP_DEVINFO_DATA, + DWORD, + PDWORD, + PBYTE, + DWORD, + PDWORD, +) +SetupDiGetDeviceRegistryProperty.restype = BOOL + +# https://learn.microsoft.com/en-us/windows/win32/api/setupapi/nf-setupapi-setupdigetdevicepropertyw +SetupDiGetDeviceProperty = setupapi.SetupDiGetDevicePropertyW +SetupDiGetDeviceProperty.argtypes = ( + HDEVINFO, + PSP_DEVINFO_DATA, + PSP_DEVPROPKEY, + PULONG, + PBYTE, + DWORD, + PDWORD, + DWORD, +) +SetupDiGetDeviceProperty.restype = BOOL + +SetupDiGetDeviceInstanceId = setupapi.SetupDiGetDeviceInstanceIdW +SetupDiGetDeviceInstanceId.argtypes = (HDEVINFO, PSP_DEVINFO_DATA, PTSTR, DWORD, PDWORD) +SetupDiGetDeviceInstanceId.restype = BOOL + +SetupDiOpenDevRegKey = setupapi.SetupDiOpenDevRegKey +SetupDiOpenDevRegKey.argtypes = (HDEVINFO, PSP_DEVINFO_DATA, DWORD, DWORD, DWORD, REGSAM) +SetupDiOpenDevRegKey.restype = HKEY + +advapi32 = ctypes.windll.LoadLibrary("Advapi32") +RegCloseKey = advapi32.RegCloseKey +RegCloseKey.argtypes = (HKEY,) +RegCloseKey.restype = LONG + +RegQueryValueEx = advapi32.RegQueryValueExW +RegQueryValueEx.argtypes = (HKEY, LPCTSTR, LPDWORD, LPDWORD, LPBYTE, LPDWORD) +RegQueryValueEx.restype = LONG + +cfgmgr32 = ctypes.windll.LoadLibrary("Cfgmgr32") +CM_Get_Parent = cfgmgr32.CM_Get_Parent +CM_Get_Parent.argtypes = (PDWORD, DWORD, ULONG) +CM_Get_Parent.restype = LONG + +CM_Get_Device_IDW = cfgmgr32.CM_Get_Device_IDW +CM_Get_Device_IDW.argtypes = (DWORD, PTSTR, ULONG, ULONG) +CM_Get_Device_IDW.restype = LONG + +CM_MapCrToWin32Err = cfgmgr32.CM_MapCrToWin32Err +CM_MapCrToWin32Err.argtypes = (DWORD, DWORD) +CM_MapCrToWin32Err.restype = DWORD + + +def get_parent_serial_number( + child_devinst: ctypes._CData, + child_vid: int | None, + child_pid: int | None, + depth: int = 0, + last_serial_number: str | None = None, +) -> str: + """Get the serial number of the parent of a device. + + Args: + child_devinst: + The device instance handle to get the parent serial number of. + child_vid: + The vendor ID of the child device. + child_pid: + The product ID of the child device. + depth: + The current iteration depth of the USB device tree. + """ + # If the traversal depth is beyond the max, abandon attempting to find the serial number. + if depth > MAX_USB_DEVICE_TREE_TRAVERSAL_DEPTH: + return last_serial_number or "" + + # Get the parent device instance. + devinst = DWORD() + ret = CM_Get_Parent(byref(devinst), child_devinst, 0) + + if ret: + win_error = CM_MapCrToWin32Err(DWORD(ret), DWORD(0)) + # If no parent available, the child was the root device. We cannot traverse further. + if win_error == ERROR_NOT_FOUND: + return last_serial_number or "" + raise WinError(win_error) + + # Get the ID of the parent device and parse it for vendor ID, product ID, and serial number. + parentHardwareID = create_unicode_buffer(250) + ret = CM_Get_Device_IDW(devinst, parentHardwareID, sizeof(parentHardwareID) - 1, 0) + if ret: + raise WinError(CM_MapCrToWin32Err(DWORD(ret), DWORD(0))) + parentHardwareID_str = parentHardwareID.value + m = USB_HW_ID_PAT.search(parentHardwareID_str) + # Return early if we have no matches (likely malformed serial, traversed too far) + if not m: + return last_serial_number or "" + + vid = None + pid = None + serial_number = None + if m.group(1): + vid = int(m.group(1), 16) + if m.group(3): + pid = int(m.group(3), 16) + if m.group(7): + serial_number = m.group(7) + # Store what we found as a fallback for malformed serial values up the chain + found_serial_number = serial_number + + # Check that the USB serial number only contains alphanumeric characters. It may be a windows + # device ID (ephemeral ID). + if serial_number and not re.match(r"^\w+$", serial_number): + serial_number = None + + if not vid or not pid: + # If PID and VID are not available at this device level, continue to the parent. + return get_parent_serial_number( + devinst, child_vid, child_pid, depth + 1, found_serial_number + ) + + if pid != child_pid or vid != child_vid: + # If the VID or PID has changed, we are no longer looking at the same physical device. The + # serial number is unknown. + return last_serial_number or "" + + # In this case, the VID and PID of the parent device are identical to the child. However, if + # there still isn't a serial number available, continue to the next parent. + if not serial_number: + return get_parent_serial_number( + devinst, child_vid, child_pid, depth + 1, found_serial_number + ) + + # Finally, the VID and PID are identical to the child and a serial number is present, return it. + return serial_number + + +def iterate_comports() -> Generator[PortInfo, None, None]: + """A generator that yields descriptions for serial ports.""" + PortsGUIDs = (GUID * 8)() # so far only seen one used, so hope 8 are enough... + ports_guids_size = DWORD() + if not SetupDiClassGuidsFromName( + "Ports", PortsGUIDs, sizeof(PortsGUIDs), byref(ports_guids_size) + ): + raise WinError() + + ModemGUIDs = (GUID * 8)() # so far only seen one used, so hope 8 are enough... + modem_guids_size = DWORD() + if not SetupDiClassGuidsFromName( + "Modem", ModemGUIDs, sizeof(ModemGUIDs), byref(modem_guids_size) + ): + raise WinError() + + GUIDs = PortsGUIDs[: ports_guids_size.value] + ModemGUIDs[: modem_guids_size.value] + + # Repeat for all possible GUIDs + for guid in GUIDs: + bInterfaceNumber = None + g_hdi = SetupDiGetClassDevs(byref(guid), None, None, DIGCF_PRESENT) + if g_hdi == INVALID_HANDLE_VALUE: + raise WinError() + + devinfo = SP_DEVINFO_DATA() + devinfo.cbSize = sizeof(devinfo) + index = 0 + while SetupDiEnumDeviceInfo(g_hdi, index, byref(devinfo)): + index += 1 + + # Get the real com port name + hkey = SetupDiOpenDevRegKey( + g_hdi, + byref(devinfo), + DICS_FLAG_GLOBAL, + 0, + DIREG_DEV, # DIREG_DRV for SW info + KEY_READ, + ) + port_name_buffer = create_unicode_buffer(250) + port_name_length = ULONG(sizeof(port_name_buffer)) + RegQueryValueEx( + hkey, "PortName", None, None, byref(port_name_buffer), byref(port_name_length) + ) + RegCloseKey(hkey) + port_name: str = port_name_buffer.value + + # Unfortunately this method also include parallel ports. We could check for names + # starting with COM or just exclude LPT and hope that other "unknown" names are serial + # ports... + if port_name.startswith("LPT"): + continue + + # Hardware ID + szHardwareID = create_unicode_buffer(250) + # Try to get ID that includes serial number + if not SetupDiGetDeviceInstanceId( + g_hdi, byref(devinfo), szHardwareID, sizeof(szHardwareID) - 1, None + ): + # Fall back to more generic hardware ID if that would fail + if not SetupDiGetDeviceRegistryProperty( + g_hdi, + byref(devinfo), + SPDRP_HARDWAREID, + None, + byref(szHardwareID), + sizeof(szHardwareID) - 1, + None, + ): + # Ignore ERROR_INSUFFICIENT_BUFFER + if GetLastError() != ERROR_INSUFFICIENT_BUFFER: + raise WinError() + szHardwareID_str: str = szHardwareID.value + + info = PortInfo(port_name, skip_link_detection=True) + + # In case of USB, make a more readable string, similar to that on other platforms + if szHardwareID_str.startswith("USB"): + m = USB_HW_ID_PAT.search(szHardwareID_str) + if m: + info.vid = int(m.group(1), 16) + if m.group(3): + info.pid = int(m.group(3), 16) + if m.group(5): + bInterfaceNumber = int(m.group(5)) + + ser_num = m.group(7) + # Check that the USB serial number only contains alphanumeric characters. It + # may be a windows device ID (ephemeral ID) for composite devices. + if not (ser_num and re.match(r"^\w+$", ser_num)): + ser_num = get_parent_serial_number(devinfo.DevInst, info.vid, info.pid) + info.serial_number = ser_num + + # Calculate a location string + loc_path_str = create_unicode_buffer(500) + if SetupDiGetDeviceRegistryProperty( + g_hdi, + byref(devinfo), + SPDRP_LOCATION_PATHS, + None, + byref(loc_path_str), + sizeof(loc_path_str) - 1, + None, + ): + location = [] + for g in re.finditer(r"USBROOT\((\w+)\)|#USB\((\w+)\)", loc_path_str.value): + if g.group(1): + location.append(f"{int(g.group(1)) + 1:d}") + else: + location.append("." if len(location) > 1 else "-") + location.append(g.group(2)) + if bInterfaceNumber is not None: + # XXX how to determine correct bConfigurationValue? + location.append(f":{'x'}.{bInterfaceNumber}") + if location: + info.location = "".join(location) + info.hwid = info.usb_info() + elif szHardwareID_str.startswith("FTDIBUS"): + m = re.search( + r"VID_([0-9a-f]{4})\+PID_([0-9a-f]{4})(\+(\w+))?", szHardwareID_str, re.I + ) + if m: + info.vid = int(m.group(1), 16) + info.pid = int(m.group(2), 16) + if m.group(4): + info.serial_number = m.group(4) + # USB location is hidden by FTDI driver :( + info.hwid = info.usb_info() + else: + info.hwid = szHardwareID_str + + # Bus Reported Device Name + szBusReportedDeviceDesc = create_unicode_buffer(250) + devPropType = ULONG() + if SetupDiGetDeviceProperty( + g_hdi, + byref(devinfo), + byref(DEVPKEY_Device_BusReportedDeviceDesc), + byref(devPropType), + byref(szBusReportedDeviceDesc), + sizeof(szBusReportedDeviceDesc) - 1, + None, + 0, + ): + info.product = szBusReportedDeviceDesc.value + + # Friendly name + szFriendlyName = create_unicode_buffer(250) + if SetupDiGetDeviceRegistryProperty( + g_hdi, + byref(devinfo), + SPDRP_FRIENDLYNAME, + None, + byref(szFriendlyName), + sizeof(szFriendlyName) - 1, + None, + ): + info.description = szFriendlyName.value + + # Manufacturer + szManufacturer = create_unicode_buffer(250) + if SetupDiGetDeviceRegistryProperty( + g_hdi, + byref(devinfo), + SPDRP_MFG, + None, + byref(szManufacturer), + sizeof(szManufacturer) - 1, + None, + ): + info.manufacturer = szManufacturer.value + yield info + SetupDiDestroyDeviceInfoList(g_hdi) + + +def comports(include_links: bool = False) -> list[PortInfo]: + return list(iterate_comports()) diff --git a/lsports/py.typed b/lsports/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0f0e161 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,64 @@ +[build-system] +requires = ["hatchling>=1.11.0"] +build-backend = "hatchling.build" + +[project] +name = "lsports" +version = "0.1.0" +description = "List serial ports." +authors = [ + {name="Ali Hamdan", email="ali.hamdan.dev@gmail.com"}, +] +readme = "README.md" +license = "MIT" +classifiers = [ + "Development Status :: 3 - Alpha", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] +keywords = ["serial", "lsports", "list-ports", "USB", "COM"] +dependencies = [] +requires-python = ">=3.7" + +[project.urls] +Homepage = "https://github.com/hamdanal/lsports" +Documentation = "https://github.com/hamdanal/lsports#lsports" +Issue-Tracker = "https://github.com/hamdanal/lsports/issues" + +[tool.hatch.build] +only-include = ["lsports"] + +[tool.tox] +legacy_tox_ini = """ +[tox] +envlist = py37,py38,py39,py310,py311,py312 +isolated_build = true +skip_missing_interpreters = true +[testenv] +deps = + pytest +commands = + pytest {posargs} +""" + +[tool.black] +line_length = 100 + +[tool.isort] +profile = "black" +line_length = 100 + +[tool.mypy] +strict = true + +[[tool.mypy.overrides]] +module = ["tests.*"] +check_untyped_defs = false +disallow_untyped_defs = false +disallow_incomplete_defs = false diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_comports.py b/tests/test_comports.py new file mode 100644 index 0000000..4aaf0aa --- /dev/null +++ b/tests/test_comports.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from lsports import PortInfo, comports + + +def test_comports(): + ports = comports() + assert isinstance(ports, list) + for port in ports: + assert isinstance(port, PortInfo) + assert isinstance(port.device, str) + assert isinstance(port.name, str) + assert isinstance(port.description, str) + assert isinstance(port.hwid, str) + if port.vid is not None: # USB + assert isinstance(port.vid, int) + assert isinstance(port.pid, int) + assert isinstance(port.location, str) + if port.serial_number is not None: + assert isinstance(port.serial_number, str) + if port.manufacturer is not None: + assert isinstance(port.manufacturer, str) + if port.product is not None: + assert isinstance(port.product, str) + if port.interface is not None: + assert isinstance(port.interface, str)