pyserial-pyusb 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyserial-pyusb
3
+ Version: 0.1.0
4
+ Summary: Pyserial backend for USB serial devices via pyusb (CDC ACM and CP210x)
5
+ Requires-Python: >=3.9
6
+ Requires-Dist: pyserial>=3.5
7
+ Requires-Dist: pyusb>=1.3.1
8
+ Provides-Extra: usbip
9
+ Requires-Dist: pyusb-usbip-backend>=0.1.0; extra == "usbip"
@@ -0,0 +1,22 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pyserial-pyusb"
7
+ version = "0.1.0"
8
+ description = "Pyserial backend for USB serial devices via pyusb (CDC ACM and CP210x)"
9
+ requires-python = ">=3.9"
10
+ dependencies = [
11
+ "pyserial>=3.5",
12
+ "pyusb>=1.3.1",
13
+ ]
14
+
15
+ [project.optional-dependencies]
16
+ usbip = ["pyusb-usbip-backend>=0.1.0"]
17
+
18
+ [tool.setuptools]
19
+ package-dir = {"" = "src"}
20
+
21
+ [tool.setuptools.packages.find]
22
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyserial-pyusb
3
+ Version: 0.1.0
4
+ Summary: Pyserial backend for USB serial devices via pyusb (CDC ACM and CP210x)
5
+ Requires-Python: >=3.9
6
+ Requires-Dist: pyserial>=3.5
7
+ Requires-Dist: pyusb>=1.3.1
8
+ Provides-Extra: usbip
9
+ Requires-Dist: pyusb-usbip-backend>=0.1.0; extra == "usbip"
@@ -0,0 +1,15 @@
1
+ pyproject.toml
2
+ src/pyserial_pyusb.egg-info/PKG-INFO
3
+ src/pyserial_pyusb.egg-info/SOURCES.txt
4
+ src/pyserial_pyusb.egg-info/dependency_links.txt
5
+ src/pyserial_pyusb.egg-info/requires.txt
6
+ src/pyserial_pyusb.egg-info/top_level.txt
7
+ src/serial_pyusb/__init__.py
8
+ src/serial_pyusb/serial.py
9
+ src/serial_pyusb/drivers/__init__.py
10
+ src/serial_pyusb/drivers/base.py
11
+ src/serial_pyusb/drivers/cdc_acm.py
12
+ src/serial_pyusb/drivers/cp210x.py
13
+ src/serial_pyusb/urlhandler/__init__.py
14
+ src/serial_pyusb/urlhandler/protocol_pyusb.py
15
+ src/serial_pyusb/urlhandler/protocol_usbip.py
@@ -0,0 +1,5 @@
1
+ pyserial>=3.5
2
+ pyusb>=1.3.1
3
+
4
+ [usbip]
5
+ pyusb-usbip-backend>=0.1.0
@@ -0,0 +1,15 @@
1
+ """Pyserial backend for USB serial devices via pyusb.
2
+
3
+ Supports local USB devices (via libusb) and remote devices (via USB/IP).
4
+ """
5
+
6
+ from serial_pyusb.serial import Serial
7
+
8
+ # Auto-register the pyusb:// URL handler with pyserial on import.
9
+ import importlib as _importlib
10
+ _serial_pkg = _importlib.import_module("serial")
11
+ _HANDLER_PACKAGE = "serial_pyusb.urlhandler"
12
+ if _HANDLER_PACKAGE not in _serial_pkg.protocol_handler_packages:
13
+ _serial_pkg.protocol_handler_packages.append(_HANDLER_PACKAGE)
14
+
15
+ __all__ = ["Serial"]
@@ -0,0 +1,7 @@
1
+ """USB serial device drivers."""
2
+
3
+ from serial_pyusb.drivers.base import USBSerialDriver
4
+ from serial_pyusb.drivers.cdc_acm import CDCACMDriver
5
+ from serial_pyusb.drivers.cp210x import CP210xDriver
6
+
7
+ __all__ = ["USBSerialDriver", "CDCACMDriver", "CP210xDriver"]
@@ -0,0 +1,85 @@
1
+ """Base class for USB serial drivers."""
2
+
3
+ import usb.core
4
+ import usb.util
5
+
6
+
7
+ class USBSerialDriver:
8
+ """Abstract base for USB serial device drivers.
9
+
10
+ Subclasses implement the vendor-specific USB control transfers needed to
11
+ configure baud rate, line parameters, and modem control signals for a
12
+ particular USB-to-serial chip.
13
+ """
14
+
15
+ def __init__(self, dev: usb.core.Device):
16
+ self.dev = dev
17
+ self.logger = None
18
+ self._ep_in = None
19
+ self._ep_out = None
20
+ self._find_endpoints()
21
+
22
+ def _find_endpoints(self):
23
+ """Locate bulk IN and OUT endpoints on the data interface."""
24
+ cfg = self.dev.get_active_configuration()
25
+ intf = self._get_data_interface(cfg)
26
+ for ep in intf:
27
+ direction = usb.util.endpoint_direction(ep.bEndpointAddress)
28
+ ep_type = usb.util.endpoint_type(ep.bmAttributes)
29
+ if ep_type == usb.util.ENDPOINT_TYPE_BULK:
30
+ if direction == usb.util.ENDPOINT_IN:
31
+ self._ep_in = ep.bEndpointAddress
32
+ elif direction == usb.util.ENDPOINT_OUT:
33
+ self._ep_out = ep.bEndpointAddress
34
+ if self._ep_in is None or self._ep_out is None:
35
+ raise usb.core.USBError("Could not find bulk IN/OUT endpoints")
36
+
37
+ def _get_data_interface(self, cfg):
38
+ """Return the USB interface that carries serial data.
39
+
40
+ Override in subclasses if the data interface is not interface 0.
41
+ """
42
+ return cfg[(0, 0)]
43
+
44
+ # -- Methods subclasses must implement --
45
+
46
+ def open(self):
47
+ """Enable the serial interface on the device."""
48
+ raise NotImplementedError
49
+
50
+ def close(self):
51
+ """Disable the serial interface on the device."""
52
+ raise NotImplementedError
53
+
54
+ def set_baudrate(self, baudrate: int):
55
+ raise NotImplementedError
56
+
57
+ def set_line_params(self, bytesize: int, parity: str, stopbits: float):
58
+ """Configure data bits, parity, and stop bits."""
59
+ raise NotImplementedError
60
+
61
+ def set_dtr(self, state: bool):
62
+ raise NotImplementedError
63
+
64
+ def set_rts(self, state: bool):
65
+ raise NotImplementedError
66
+
67
+ def get_modem_status(self) -> dict:
68
+ """Return dict with bool keys: cts, dsr, ri, cd."""
69
+ return {"cts": True, "dsr": True, "ri": False, "cd": True}
70
+
71
+ def set_break(self, state: bool):
72
+ pass
73
+
74
+ # -- Data transfer --
75
+
76
+ def write(self, data: bytes, timeout: int = 0) -> int:
77
+ return self.dev.write(self._ep_out, data, timeout=timeout)
78
+
79
+ def read(self, size: int, timeout: int = 0) -> bytes:
80
+ return bytes(self.dev.read(self._ep_in, size, timeout=timeout))
81
+
82
+ @classmethod
83
+ def matches(cls, dev: usb.core.Device) -> bool:
84
+ """Return True if this driver can handle the given USB device."""
85
+ raise NotImplementedError
@@ -0,0 +1,140 @@
1
+ """Standard USB CDC ACM serial driver."""
2
+
3
+ import struct
4
+
5
+ import usb.core
6
+ import usb.util
7
+
8
+ from serial_pyusb.drivers.base import USBSerialDriver
9
+
10
+ # CDC class requests
11
+ _SET_LINE_CODING = 0x20
12
+ _GET_LINE_CODING = 0x21
13
+ _SET_CONTROL_LINE_STATE = 0x22
14
+ _SEND_BREAK = 0x23
15
+
16
+ # Line coding: parity
17
+ _PARITY = {"N": 0, "O": 1, "E": 2, "M": 3, "S": 4}
18
+
19
+ # Line coding: stop bits
20
+ _STOP_BITS = {1: 0, 1.5: 1, 2: 2}
21
+
22
+ # USB CDC interface class/subclass
23
+ _CDC_CLASS = 0x02
24
+ _CDC_ACM_SUBCLASS = 0x02
25
+ _CDC_DATA_CLASS = 0x0A
26
+
27
+
28
+ class CDCACMDriver(USBSerialDriver):
29
+ """Driver for standard USB CDC ACM serial devices."""
30
+
31
+ def __init__(self, dev: usb.core.Device):
32
+ self._ctrl_intf = 0
33
+ self._data_intf = 1
34
+ self._dtr = False
35
+ self._rts = False
36
+ self._baudrate = 9600
37
+ self._stop_val = 0
38
+ self._parity_val = 0
39
+ self._data_bits = 8
40
+ super().__init__(dev)
41
+ self._detect_interfaces()
42
+
43
+ def _detect_interfaces(self):
44
+ """Find the CDC control and data interfaces from descriptors."""
45
+ try:
46
+ cfg = self.dev.get_active_configuration()
47
+ except usb.core.USBError:
48
+ self.dev.set_configuration()
49
+ cfg = self.dev.get_active_configuration()
50
+ for intf in cfg:
51
+ if (intf.bInterfaceClass == _CDC_CLASS and
52
+ intf.bInterfaceSubClass == _CDC_ACM_SUBCLASS):
53
+ self._ctrl_intf = intf.bInterfaceNumber
54
+ elif intf.bInterfaceClass == _CDC_DATA_CLASS:
55
+ self._data_intf = intf.bInterfaceNumber
56
+
57
+ def _get_data_interface(self, cfg):
58
+ return cfg[(self._data_intf, 0)]
59
+
60
+ def _send_line_state(self):
61
+ """Send SET_CONTROL_LINE_STATE with current DTR/RTS."""
62
+ value = (int(self._dtr)) | (int(self._rts) << 1)
63
+ if self.logger:
64
+ self.logger.debug("cdc_acm SET_CONTROL_LINE_STATE val=0x%04x (DTR=%s RTS=%s)", value, self._dtr, self._rts)
65
+ self.dev.ctrl_transfer(
66
+ 0x21, _SET_CONTROL_LINE_STATE, value, self._ctrl_intf, timeout=1000
67
+ )
68
+
69
+ def open(self):
70
+ self._dtr = True
71
+ self._rts = True
72
+ self._send_line_state()
73
+
74
+ def close(self):
75
+ self._dtr = False
76
+ self._rts = False
77
+ self._send_line_state()
78
+
79
+ def _send_line_coding(self):
80
+ """Send SET_LINE_CODING with current cached values."""
81
+ payload = struct.pack(
82
+ "<IBBB",
83
+ self._baudrate,
84
+ self._stop_val,
85
+ self._parity_val,
86
+ self._data_bits,
87
+ )
88
+ if self.logger:
89
+ self.logger.debug(
90
+ "cdc_acm SET_LINE_CODING baud=%d stop=%d parity=%d bits=%d payload=%s",
91
+ self._baudrate, self._stop_val, self._parity_val, self._data_bits,
92
+ payload.hex(),
93
+ )
94
+ self.dev.ctrl_transfer(
95
+ 0x21, _SET_LINE_CODING, 0, self._ctrl_intf, payload, timeout=1000
96
+ )
97
+
98
+ def set_baudrate(self, baudrate: int):
99
+ self._baudrate = baudrate
100
+ self._send_line_coding()
101
+
102
+ def set_line_params(self, bytesize: int, parity: str, stopbits: float):
103
+ self._stop_val = _STOP_BITS.get(stopbits, 0)
104
+ self._parity_val = _PARITY.get(parity, 0)
105
+ self._data_bits = bytesize
106
+ self._send_line_coding()
107
+
108
+ def set_dtr(self, state: bool):
109
+ self._dtr = state
110
+ self._send_line_state()
111
+
112
+ def set_rts(self, state: bool):
113
+ self._rts = state
114
+ self._send_line_state()
115
+
116
+ def set_break(self, state: bool):
117
+ # SEND_BREAK: wValue = duration in ms, 0xFFFF = on until cleared, 0 = off
118
+ value = 0xFFFF if state else 0x0000
119
+ if self.logger:
120
+ self.logger.debug("cdc_acm SEND_BREAK val=0x%04x", value)
121
+ self.dev.ctrl_transfer(
122
+ 0x21, _SEND_BREAK, value, self._ctrl_intf, timeout=1000
123
+ )
124
+
125
+ @classmethod
126
+ def matches(cls, dev: usb.core.Device) -> bool:
127
+ """Match any device with a CDC ACM interface."""
128
+ try:
129
+ cfg = dev.get_active_configuration()
130
+ except usb.core.USBError:
131
+ try:
132
+ dev.set_configuration()
133
+ cfg = dev.get_active_configuration()
134
+ except usb.core.USBError:
135
+ return False
136
+ for intf in cfg:
137
+ if (intf.bInterfaceClass == _CDC_CLASS and
138
+ intf.bInterfaceSubClass == _CDC_ACM_SUBCLASS):
139
+ return True
140
+ return False
@@ -0,0 +1,104 @@
1
+ """Silicon Labs CP210x USB-to-UART driver."""
2
+
3
+ import struct
4
+
5
+ import usb.core
6
+
7
+ from serial_pyusb.drivers.base import USBSerialDriver
8
+
9
+ # CP210x vendor-specific request codes
10
+ _IFC_ENABLE = 0x00
11
+ _SET_LINE_CTL = 0x03
12
+ _SET_FLOW = 0x13
13
+ _SET_MHS = 0x07
14
+ _GET_MDMSTS = 0x08
15
+ _SET_BAUDRATE = 0x1E
16
+
17
+ # Line control encoding
18
+ _DATA_BITS = {5: 0x0500, 6: 0x0600, 7: 0x0700, 8: 0x0800}
19
+ _PARITY = {"N": 0x0000, "O": 0x0010, "E": 0x0020, "M": 0x0030, "S": 0x0040}
20
+ _STOP_BITS = {1: 0x0000, 1.5: 0x0100, 2: 0x0200}
21
+
22
+ # Modem handshake bits
23
+ _DTR_ON = 0x0101
24
+ _DTR_OFF = 0x0100
25
+ _RTS_ON = 0x0202
26
+ _RTS_OFF = 0x0200
27
+
28
+ # Modem status bits
29
+ _CTS = 0x10
30
+ _DSR = 0x20
31
+ _RI = 0x40
32
+ _DCD = 0x80
33
+
34
+ # Known CP210x VID/PIDs
35
+ _VID = 0x10C4
36
+ _PIDS = {0xEA60, 0xEA61, 0xEA70, 0xEA80}
37
+
38
+
39
+ class CP210xDriver(USBSerialDriver):
40
+ """Driver for Silicon Labs CP210x USB-to-UART bridges."""
41
+
42
+ def __init__(self, dev: usb.core.Device):
43
+ self._intf = 0
44
+ super().__init__(dev)
45
+
46
+ def _ctrl(self, request, value=0, data=None):
47
+ """Send a vendor-specific control transfer to the CP210x."""
48
+ if self.logger:
49
+ self.logger.debug(
50
+ "cp210x ctrl_out req=0x%02x val=0x%04x data=%s",
51
+ request, value, data.hex() if data else None,
52
+ )
53
+ if data is not None:
54
+ self.dev.ctrl_transfer(0x41, request, value, self._intf, data, timeout=1000)
55
+ else:
56
+ self.dev.ctrl_transfer(0x41, request, value, self._intf, timeout=1000)
57
+
58
+ def _ctrl_in(self, request, length, value=0):
59
+ """Read a vendor-specific control transfer from the CP210x."""
60
+ result = self.dev.ctrl_transfer(0xC1, request, value, self._intf, length, timeout=1000)
61
+ if self.logger:
62
+ self.logger.debug(
63
+ "cp210x ctrl_in req=0x%02x val=0x%04x result=%s",
64
+ request, value, bytes(result).hex(),
65
+ )
66
+ return result
67
+
68
+ def open(self):
69
+ self._ctrl(_IFC_ENABLE, 0x0001)
70
+
71
+ def close(self):
72
+ self._ctrl(_IFC_ENABLE, 0x0000)
73
+
74
+ def set_baudrate(self, baudrate: int):
75
+ payload = struct.pack("<I", baudrate)
76
+ self._ctrl(_SET_BAUDRATE, data=payload)
77
+
78
+ def set_line_params(self, bytesize: int, parity: str, stopbits: float):
79
+ value = _DATA_BITS.get(bytesize, 0x0800) | _PARITY.get(parity, 0) | _STOP_BITS.get(stopbits, 0)
80
+ self._ctrl(_SET_LINE_CTL, value)
81
+
82
+ def set_dtr(self, state: bool):
83
+ self._ctrl(_SET_MHS, _DTR_ON if state else _DTR_OFF)
84
+
85
+ def set_rts(self, state: bool):
86
+ self._ctrl(_SET_MHS, _RTS_ON if state else _RTS_OFF)
87
+
88
+ def get_modem_status(self) -> dict:
89
+ data = self._ctrl_in(_GET_MDMSTS, 1)
90
+ status = data[0]
91
+ return {
92
+ "cts": bool(status & _CTS),
93
+ "dsr": bool(status & _DSR),
94
+ "ri": bool(status & _RI),
95
+ "cd": bool(status & _DCD),
96
+ }
97
+
98
+ def set_break(self, state: bool):
99
+ # CP210x SET_BREAK: request 0x05, wValue 1=on 0=off
100
+ self._ctrl(0x05, 0x0001 if state else 0x0000)
101
+
102
+ @classmethod
103
+ def matches(cls, dev: usb.core.Device) -> bool:
104
+ return dev.idVendor == _VID and dev.idProduct in _PIDS
@@ -0,0 +1,480 @@
1
+ """Pyserial Serial class backed by pyusb.
2
+
3
+ URL format:
4
+ pyusb://host[:port]/VID:PID[?serial=X&instance=N&logging=LEVEL]
5
+ pyusb:///direct/VID:PID[?serial=X&instance=N&logging=LEVEL]
6
+
7
+ Remote examples (via USB/IP):
8
+ pyusb://192.168.1.20/10c4:ea60
9
+ pyusb://192.168.1.20:3240/10c4:ea60?serial=0001
10
+ pyusb://192.168.1.20/10c4:ea60?logging=debug
11
+
12
+ Local examples (direct via libusb):
13
+ pyusb:///direct/10c4:ea60
14
+ pyusb:///direct/10c4:ea60?serial=0001&instance=1
15
+ """
16
+
17
+ import errno
18
+ import logging
19
+ import time
20
+ import urllib.parse
21
+
22
+ import usb.core
23
+ import usb.util
24
+
25
+ from serial.serialutil import (
26
+ SerialBase,
27
+ SerialException,
28
+ PortNotOpenError,
29
+ to_bytes,
30
+ )
31
+
32
+ from serial_pyusb.drivers.cp210x import CP210xDriver
33
+ from serial_pyusb.drivers.cdc_acm import CDCACMDriver
34
+
35
+ LOGGER_NAME = "pySerial.pyusb"
36
+
37
+ # Driver classes in priority order (most specific first)
38
+ _DRIVERS = [CP210xDriver, CDCACMDriver]
39
+
40
+
41
+ def _select_driver(dev):
42
+ """Auto-select a driver for the given USB device."""
43
+ for cls in _DRIVERS:
44
+ if cls.matches(dev):
45
+ return cls(dev)
46
+ raise SerialException(
47
+ f"No driver found for USB device "
48
+ f"{dev.idVendor:04x}:{dev.idProduct:04x}"
49
+ )
50
+
51
+
52
+ class Serial(SerialBase):
53
+ """Pyserial port backed by a USB serial device via pyusb."""
54
+
55
+ BAUDRATES = (
56
+ 50, 75, 110, 134, 150, 200, 300, 600, 1200, 1800, 2400, 4800,
57
+ 9600, 19200, 38400, 57600, 115200, 230400, 460800, 500000,
58
+ 576000, 921600, 1000000, 1152000, 1500000, 2000000, 2500000,
59
+ 3000000, 3500000, 4000000,
60
+ )
61
+
62
+ # Minimum USB bulk read size — USB bulk transfers deliver whole
63
+ # packets, so requesting fewer bytes than the device sends causes
64
+ # EOVERFLOW. 64 bytes matches the full-speed bulk max packet size.
65
+ _USB_READ_SIZE = 64
66
+
67
+ def __init__(self, *args, **kwargs):
68
+ self._backend = None
69
+ self._usb_dev = None
70
+ self._driver = None
71
+ self._driver_cls = None
72
+ self.logger = None
73
+ self._last_baudrate = None
74
+ self._last_line_params = None
75
+ self._read_buf = bytearray()
76
+ super().__init__(*args, **kwargs)
77
+
78
+ def open(self):
79
+ if self.is_open:
80
+ raise SerialException("Port is already open.")
81
+ if self._port is None:
82
+ raise SerialException("Port must be configured before it can be used.")
83
+
84
+ params = self._parse_url(self.portstr)
85
+
86
+ if self._backend is not None:
87
+ # Reopen after close — use reconnect logic which reuses the
88
+ # existing connection instead of opening a new one
89
+ # (USB/IP servers are often single-connection).
90
+ self.is_open = True
91
+ self._reconnect_driver()
92
+ if not self._dsrdtr:
93
+ self._update_dtr_state()
94
+ if not self._rtscts:
95
+ self._update_rts_state()
96
+ return
97
+
98
+ if params["host"] is not None:
99
+ from usbip_backend import get_backend
100
+ self._backend = get_backend(params["host"], port=params["port"])
101
+ else:
102
+ self._backend = None # use default pyusb backend (libusb)
103
+
104
+ find_kwargs = {
105
+ "idVendor": params["vid"],
106
+ "idProduct": params["pid"],
107
+ }
108
+ if self._backend is not None:
109
+ find_kwargs["backend"] = self._backend
110
+ if params["serial"] is not None:
111
+ find_kwargs["serial_number"] = params["serial"]
112
+
113
+ # Find matching device(s)
114
+ instance = params["instance"]
115
+ if instance > 0:
116
+ devs = list(usb.core.find(find_all=True, **find_kwargs))
117
+ if instance >= len(devs):
118
+ raise SerialException(
119
+ f"USB device {params['vid']:04x}:{params['pid']:04x} "
120
+ f"instance {instance} not found "
121
+ f"({len(devs)} device(s) available)"
122
+ )
123
+ self._usb_dev = devs[instance]
124
+ else:
125
+ self._usb_dev = usb.core.find(**find_kwargs)
126
+
127
+ if self._usb_dev is None:
128
+ raise SerialException(
129
+ f"USB device {params['vid']:04x}:{params['pid']:04x} not found"
130
+ )
131
+
132
+ self._driver = _select_driver(self._usb_dev)
133
+ self._driver_cls = type(self._driver)
134
+ self._driver.logger = self.logger
135
+ if self.logger:
136
+ self.logger.info(
137
+ "opened %04x:%04x using %s",
138
+ self._usb_dev.idVendor, self._usb_dev.idProduct,
139
+ type(self._driver).__name__,
140
+ )
141
+ self._driver.open()
142
+ self.is_open = True
143
+ self._reconfigure_port()
144
+ if not self._dsrdtr:
145
+ self._update_dtr_state()
146
+ if not self._rtscts:
147
+ self._update_rts_state()
148
+
149
+ def close(self):
150
+ if self.is_open:
151
+ try:
152
+ self._driver.close()
153
+ except Exception:
154
+ pass
155
+ self._driver = None
156
+ # Keep self._backend and self._usb_dev alive for reuse on reopen
157
+ self.is_open = False
158
+ super().close()
159
+
160
+ def _reconfigure_port(self):
161
+ if not self.is_open:
162
+ return
163
+ if self._baudrate != self._last_baudrate:
164
+ self._driver.set_baudrate(self._baudrate)
165
+ self._last_baudrate = self._baudrate
166
+ line_params = (self._bytesize, self._parity, self._stopbits)
167
+ if line_params != self._last_line_params:
168
+ self._driver.set_line_params(*line_params)
169
+ self._last_line_params = line_params
170
+
171
+ def _parse_url(self, url):
172
+ """Parse a pyusb:// URL and return a dict of connection parameters.
173
+
174
+ Formats:
175
+ pyusb://host[:port]/VID:PID[?serial=X&instance=N&logging=LEVEL]
176
+ pyusb:///direct/VID:PID[?serial=X&instance=N&logging=LEVEL]
177
+ """
178
+ parts = urllib.parse.urlsplit(url)
179
+ if parts.scheme == "usbip":
180
+ raise SerialException(
181
+ 'The usbip:// scheme has been replaced by pyusb://. '
182
+ 'Use pyusb://host[:port]/VID:PID instead of usbip://host/busid'
183
+ )
184
+ if parts.scheme != "pyusb":
185
+ raise SerialException(
186
+ f'Expected URL starting with "pyusb://", got {parts.scheme!r}'
187
+ )
188
+
189
+ query = urllib.parse.parse_qs(parts.query)
190
+
191
+ # Logging setup
192
+ if "logging" in query:
193
+ level_name = query["logging"][0].upper()
194
+ level = getattr(logging, level_name, None)
195
+ if level is not None:
196
+ self.logger = logging.getLogger(LOGGER_NAME)
197
+ self.logger.setLevel(level)
198
+ logging.basicConfig()
199
+
200
+ host = parts.hostname or None
201
+ port = parts.port or 3240
202
+
203
+ path = parts.path.lstrip("/")
204
+
205
+ # Local/direct mode: pyusb:///direct/VID:PID
206
+ if path.startswith("direct/"):
207
+ if host is not None:
208
+ raise SerialException(
209
+ f'"direct" mode cannot be used with a host: {url}'
210
+ )
211
+ host = None
212
+ vid_pid = path[len("direct/"):]
213
+ elif host is not None:
214
+ # Remote mode: pyusb://host/VID:PID
215
+ vid_pid = path
216
+ else:
217
+ raise SerialException(
218
+ f'Expected a host or "direct/" prefix. '
219
+ f'Format: pyusb://host/VID:PID or pyusb:///direct/VID:PID'
220
+ )
221
+
222
+ # Parse VID:PID
223
+ if ":" not in vid_pid:
224
+ raise SerialException(
225
+ f"Expected VID:PID in URL path, got {vid_pid!r}"
226
+ )
227
+ try:
228
+ vid_str, pid_str = vid_pid.split(":", 1)
229
+ vid = int(vid_str, 16)
230
+ pid = int(pid_str, 16)
231
+ except ValueError:
232
+ raise SerialException(
233
+ f"Invalid VID:PID {vid_pid!r} — expected hex values like 10c4:ea60"
234
+ )
235
+
236
+ serial = query.get("serial", [None])[0]
237
+ instance = int(query.get("instance", [0])[0])
238
+
239
+ return {
240
+ "host": host,
241
+ "port": port,
242
+ "vid": vid,
243
+ "pid": pid,
244
+ "serial": serial,
245
+ "instance": instance,
246
+ }
247
+
248
+ # -- Reconnect handling --
249
+
250
+ # Maximum time (seconds) to wait for a device to re-enumerate after
251
+ # a disconnect (e.g. ESP32 resetting into bootloader mode).
252
+ _RECONNECT_TIMEOUT = 5.0
253
+ _RECONNECT_INTERVAL = 0.1
254
+
255
+ def _is_connection_error(self, exc):
256
+ """Return True if the USBError indicates a broken connection."""
257
+ msg = str(exc).lower()
258
+ return any(k in msg for k in ("connection closed", "receive timeout",
259
+ "send failed", "receive failed"))
260
+
261
+ def _clear_backend_cache(self):
262
+ """Clear cached descriptors so the driver reads fresh ones."""
263
+ if self._backend is not None and hasattr(self._backend, "_cache"):
264
+ self._backend._cache.clear()
265
+
266
+ def _reconnect_driver(self):
267
+ """Rebuild the driver after a disconnect.
268
+
269
+ For USB/IP backends, the backend's _reopen() (triggered on the
270
+ next submit()) handles the TCP reconnect and re-import
271
+ automatically. We just need to rebuild the driver so it picks
272
+ up fresh endpoint info, then reconfigure baud/line params.
273
+
274
+ Does NOT call driver.open() or touch modem control lines -- the
275
+ caller (esptool reset logic) is in charge of DTR/RTS sequencing.
276
+ """
277
+ driver_cls = self._driver_cls or type(self._driver)
278
+ self._clear_backend_cache()
279
+ deadline = time.monotonic() + self._RECONNECT_TIMEOUT
280
+ while True:
281
+ try:
282
+ self._driver = driver_cls(self._usb_dev)
283
+ self._driver.logger = self.logger
284
+ self._last_baudrate = None
285
+ self._last_line_params = None
286
+ self._read_buf.clear()
287
+ self._reconfigure_port()
288
+ if self.logger:
289
+ self.logger.info("reconnected")
290
+ return
291
+ except (usb.core.USBError, SerialException) as e:
292
+ if time.monotonic() >= deadline:
293
+ raise SerialException(
294
+ f"Device did not recover within "
295
+ f"{self._RECONNECT_TIMEOUT}s: {e}"
296
+ )
297
+ if self.logger:
298
+ self.logger.debug("reconnect attempt failed: %s, retrying...", e)
299
+ self._clear_backend_cache()
300
+ time.sleep(self._RECONNECT_INTERVAL)
301
+
302
+ # -- Read / Write --
303
+
304
+ def read(self, size=1):
305
+ if not self.is_open:
306
+ raise PortNotOpenError()
307
+ if size == 0:
308
+ return b""
309
+
310
+ # Convert pyserial timeout convention to USB timeout in ms.
311
+ # None = blocking (very long timeout), 0 = non-blocking, float = seconds
312
+ if self._timeout is None:
313
+ usb_timeout = 0 # pyusb 0 = unlimited
314
+ elif self._timeout == 0:
315
+ usb_timeout = 1 # minimal non-blocking
316
+ else:
317
+ usb_timeout = int(self._timeout * 1000)
318
+
319
+ data = bytearray()
320
+ # Return buffered data first
321
+ if self._read_buf:
322
+ n = min(size, len(self._read_buf))
323
+ data.extend(self._read_buf[:n])
324
+ del self._read_buf[:n]
325
+
326
+ while len(data) < size:
327
+ # Always read at least _USB_READ_SIZE to avoid EOVERFLOW
328
+ # on USB bulk endpoints.
329
+ read_size = max(size - len(data), self._USB_READ_SIZE)
330
+ try:
331
+ chunk = self._driver.read(read_size, timeout=usb_timeout)
332
+ needed = size - len(data)
333
+ data.extend(chunk[:needed])
334
+ if len(chunk) > needed:
335
+ self._read_buf.extend(chunk[needed:])
336
+ except usb.core.USBTimeoutError:
337
+ break
338
+ except usb.core.USBError as e:
339
+ if "timeout" in str(e).lower():
340
+ break
341
+ if self._is_connection_error(e):
342
+ if self.logger:
343
+ self.logger.info("read: connection lost, reconnecting...")
344
+ self._reconnect_driver()
345
+ continue
346
+ raise SerialException(f"USB read error: {e}")
347
+ # After first chunk, don't block waiting for more
348
+ if self._timeout is not None:
349
+ break
350
+ if self.logger:
351
+ self.logger.debug("read %d bytes: %s", len(data), data.hex())
352
+ return bytes(data)
353
+
354
+ def write(self, data):
355
+ if not self.is_open:
356
+ raise PortNotOpenError()
357
+ data = to_bytes(data)
358
+ if not data:
359
+ return 0
360
+
361
+ if self._write_timeout is None:
362
+ usb_timeout = 0
363
+ elif self._write_timeout == 0:
364
+ usb_timeout = 1
365
+ else:
366
+ usb_timeout = int(self._write_timeout * 1000)
367
+
368
+ if self.logger:
369
+ self.logger.debug("write %d bytes: %s", len(data), data.hex())
370
+ try:
371
+ return self._driver.write(data, timeout=usb_timeout)
372
+ except usb.core.USBError as e:
373
+ if self._is_connection_error(e):
374
+ if self.logger:
375
+ self.logger.info("write: connection lost, reconnecting...")
376
+ self._reconnect_driver()
377
+ return self._driver.write(data, timeout=usb_timeout)
378
+ raise SerialException(f"USB write error: {e}")
379
+
380
+ @property
381
+ def in_waiting(self):
382
+ if not self.is_open:
383
+ raise PortNotOpenError()
384
+ # USB bulk endpoints don't have a queryable input buffer size
385
+ return 0
386
+
387
+ def reset_input_buffer(self):
388
+ if not self.is_open:
389
+ raise PortNotOpenError()
390
+ # USB bulk endpoints don't buffer data on our side, so there's
391
+ # nothing to drain. Attempting a short-timeout USB read over
392
+ # USB/IP is expensive due to server-side timeout margins.
393
+
394
+ def reset_output_buffer(self):
395
+ if not self.is_open:
396
+ raise PortNotOpenError()
397
+
398
+ def fileno(self):
399
+ raise OSError(errno.ENOTTY, "pyusb serial port has no file descriptor")
400
+
401
+ def setDTR(self, state):
402
+ self.dtr = state
403
+
404
+ def setRTS(self, state):
405
+ self.rts = state
406
+
407
+ def flush(self):
408
+ if not self.is_open:
409
+ return
410
+
411
+ # -- Modem control lines --
412
+
413
+ def _update_rts_state(self):
414
+ if self.is_open:
415
+ try:
416
+ self._driver.set_rts(self._rts_state)
417
+ except usb.core.USBError as e:
418
+ if self._is_connection_error(e):
419
+ self._reconnect_driver()
420
+ self._driver.set_rts(self._rts_state)
421
+ else:
422
+ raise
423
+
424
+ def _update_dtr_state(self):
425
+ if self.is_open:
426
+ try:
427
+ self._driver.set_dtr(self._dtr_state)
428
+ except usb.core.USBError as e:
429
+ if self._is_connection_error(e):
430
+ self._reconnect_driver()
431
+ self._driver.set_dtr(self._dtr_state)
432
+ else:
433
+ raise
434
+
435
+ def _update_break_state(self):
436
+ if self.is_open:
437
+ try:
438
+ self._driver.set_break(self._break_state)
439
+ except usb.core.USBError as e:
440
+ if self._is_connection_error(e):
441
+ self._reconnect_driver()
442
+ self._driver.set_break(self._break_state)
443
+ else:
444
+ raise
445
+
446
+ @property
447
+ def pid(self):
448
+ if self._usb_dev is not None:
449
+ return self._usb_dev.idProduct
450
+ return None
451
+
452
+ @property
453
+ def vid(self):
454
+ if self._usb_dev is not None:
455
+ return self._usb_dev.idVendor
456
+ return None
457
+
458
+ @property
459
+ def cts(self):
460
+ if not self.is_open:
461
+ raise PortNotOpenError()
462
+ return self._driver.get_modem_status()["cts"]
463
+
464
+ @property
465
+ def dsr(self):
466
+ if not self.is_open:
467
+ raise PortNotOpenError()
468
+ return self._driver.get_modem_status()["dsr"]
469
+
470
+ @property
471
+ def ri(self):
472
+ if not self.is_open:
473
+ raise PortNotOpenError()
474
+ return self._driver.get_modem_status()["ri"]
475
+
476
+ @property
477
+ def cd(self):
478
+ if not self.is_open:
479
+ raise PortNotOpenError()
480
+ return self._driver.get_modem_status()["cd"]
@@ -0,0 +1,10 @@
1
+ """Pyserial URL handler for pyusb:// scheme.
2
+
3
+ Register this handler by calling serial_pyusb.register() or by
4
+ appending 'serial_pyusb.urlhandler' to serial.protocol_handler_packages
5
+ before calling serial.serial_for_url().
6
+ """
7
+
8
+ from serial_pyusb.serial import Serial
9
+
10
+ __all__ = ["Serial"]
@@ -0,0 +1,10 @@
1
+ """Pyserial URL handler for usbip:// scheme.
2
+
3
+ Register this handler by calling serial_pyusb.register() or by
4
+ appending 'serial_pyusb.urlhandler' to serial.protocol_handler_packages
5
+ before calling serial.serial_for_url().
6
+ """
7
+
8
+ from serial_pyusb.serial import Serial
9
+
10
+ __all__ = ["Serial"]