Skip to content

Commit e21f31f

Browse files
tannewtclaude
andcommitted
Rename project to pyserial-pyusb / serial_pyusb and add PyPI publish workflow
Rename from pyserial-usbip/pyserial_usbip to pyserial-pyusb/serial_pyusb. Move pyusb-usbip-backend to an optional "usbip" dependency. Add GitHub Actions workflow for publishing to PyPI via trusted publishing (OIDC). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
0 parents  commit e21f31f

File tree

14 files changed

+1080
-0
lines changed

14 files changed

+1080
-0
lines changed

.github/workflows/publish.yml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: Publish to PyPI
2+
3+
on:
4+
release:
5+
types: [published]
6+
7+
jobs:
8+
build:
9+
runs-on: ubuntu-latest
10+
steps:
11+
- uses: actions/checkout@v4
12+
- uses: actions/setup-python@v5
13+
with:
14+
python-version: "3.x"
15+
- run: pip install build
16+
- run: python -m build
17+
- uses: actions/upload-artifact@v4
18+
with:
19+
name: dist
20+
path: dist/
21+
22+
publish:
23+
needs: build
24+
runs-on: ubuntu-latest
25+
environment: pypi
26+
permissions:
27+
id-token: write
28+
steps:
29+
- uses: actions/download-artifact@v4
30+
with:
31+
name: dist
32+
path: dist/
33+
- uses: pypa/gh-action-pypi-publish@release/v1

pyproject.toml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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"]

src/serial_pyusb/__init__.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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"]
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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"]

src/serial_pyusb/drivers/base.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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 commit comments

Comments
 (0)