Skip to content

Commit 5a5b923

Browse files
author
Alexander Brinkman
committed
2 parents a9e8434 + 8674a71 commit 5a5b923

10 files changed

Lines changed: 132 additions & 78 deletions

File tree

.github/workflows/lint.yml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,7 @@ jobs:
2222
- name: Install dependencies
2323
run: |
2424
python -m pip install --upgrade pip
25-
pip install ruff mypy
26-
pip install -r requirements.txt
25+
pip install -e ".[dev]"
2726
2827
- name: Run Ruff linter
2928
run: ruff check .

.github/workflows/publish.yml

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,21 +8,25 @@ on:
88
jobs:
99
publish:
1010
runs-on: ubuntu-latest
11+
environment: pypi
1112
permissions:
13+
# IMPORTANT: this permission is mandatory for Trusted Publishing
14+
id-token: write
1215
contents: read
1316

1417
steps:
1518
- uses: actions/checkout@v4
1619

1720
- name: Set up Python
18-
uses: actions/setup-python@v4
21+
uses: actions/setup-python@v5
1922
with:
20-
python-version: "3.14"
23+
python-version: "3.12"
2124

2225
- name: Install build dependencies
2326
run: |
2427
python -m pip install --upgrade pip
2528
pip install build twine
29+
pip install -e ".[dev]"
2630
2731
- name: Verify tag format
2832
run: |
@@ -39,5 +43,3 @@ jobs:
3943

4044
- name: Publish to PyPI
4145
uses: pypa/gh-action-pypi-publish@release/v1
42-
with:
43-
password: ${{ secrets.PYPI_API_TOKEN }}

README.md

Lines changed: 87 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -4,58 +4,115 @@
44
[![REUSE Compliant](https://img.shields.io/badge/reuse-compliant-green.svg)](https://reuse.software/)
55
[![Lint](https://github.com/abrinkman/usbipd-python/actions/workflows/lint.yml/badge.svg)](https://github.com/abrinkman/usbipd-python/actions/workflows/lint.yml)
66

7-
A USB/IP server written in Python3 for sharing USB devices over the network, based on usbipd concepts. Works on MacOS using the `pyusb` library, but any system that supports `pyusb` and `libusb` should work.
7+
A USB/IP server written in Python 3 for sharing USB devices over the network. This implementation uses the `pyusb` library and the `libusb` backend to support cross-platform USB device access on macOS and Linux.
88

9-
Note: this is a very early proof-of-concept implementation, which was mainly built for learning purposes with
10-
Copilot assistance. For simple devices it seems to work OK, however for more complex or HID based devices you
11-
may run into issues. Some will be because of code quality, however other issues are more fundamental. For
12-
intance, HID devices are handled by the OS kernel drivers on Windows and MacOS and these drivers cannot be
13-
detached easily without writing OS native code and/or drivers. Contributions are welcome!
9+
> **Note:** This is an early-stage implementation, primarily created for learning purposes. Simple USB devices generally work well, but more complex or HID-based devices may experience issues. HID devices are managed by OS kernel drivers on macOS and Windows, making them difficult to detach without OS-native code or custom drivers. Contributions are welcome!
1410
15-
## Preparing the environment
11+
## Installation
1612

17-
Make sure you have a Python environment, with the required packages present. Or create a venv:
13+
### Users
1814

19-
1. Create venv:
15+
Install from PyPI:
16+
17+
```bash
18+
pip install usbipd-python
19+
usbipd --help
20+
```
21+
22+
### Developers
23+
24+
1. Clone the repository:
2025

2126
```bash
22-
python3 -m venv .venv
27+
git clone https://github.com/abrinkman/usbipd-python.git
28+
cd usbipd-python
2329
```
2430

25-
2. Load the venv:
31+
2. Create and activate a virtual environment:
2632

2733
```bash
34+
python3 -m venv .venv
2835
source .venv/bin/activate
2936
```
3037

31-
3. Install requirements:
38+
3. Install in development mode with dev dependencies:
3239

3340
```bash
34-
pip install -r requirements.txt
41+
pip install -e ".[dev]"
3542
```
3643

37-
## Running the application
44+
## System Requirements
3845

39-
1. List USB devices:
46+
- **Python:** 3.9 or higher
47+
- **libusb:** Install using your system package manager
48+
- macOS: `brew install libusb`
49+
- Linux (Debian/Ubuntu): `sudo apt-get install libusb-1.0-0-dev`
50+
- Linux (Fedora/RHEL): `sudo dnf install libusbx-devel`
4051

41-
```bash
42-
./usbipd.py list
43-
```
52+
## Usage
4453

45-
2. Bind a USB device by its bus ID:
54+
### List USB Devices
4655

47-
```bash
48-
./usbipd.py bind --bus-id <bus-id>
49-
```
56+
Display all available USB devices:
5057

51-
Note that the application requires a bus-id, but the bindings are actually stored using the device's VID:PID:serial for persistence, so device binding remains valid even
52-
if the bus-id changes. Devices without a serial number are matched by VID:PID only.
58+
```bash
59+
usbipd-python list
60+
```
5361

54-
3. Start the USBIP server:
62+
### Bind a Device
5563

56-
```bash
57-
sudo ./usbipd.py start
58-
```
64+
Bind a USB device by its bus ID to make it available for sharing:
65+
66+
```bash
67+
usbipd-python bind --bus-id <bus-id>
68+
```
69+
70+
Bindings are stored persistently using the device's VID:PID:serial for future recognition, even if the bus ID changes. Devices without a serial number are matched by VID:PID only.
71+
72+
### Start the Server
73+
74+
Start the USB/IP server (requires root/sudo on macOS):
75+
76+
```bash
77+
sudo usbipd-python start
78+
```
79+
80+
Add `-v` or `--verbose` for debug output:
81+
82+
```bash
83+
sudo usbipd-python -v start
84+
```
85+
86+
### Connect to the Server
87+
88+
Use a USB/IP client on another machine to connect and access shared devices.
89+
90+
## Development
91+
92+
### Code Quality
93+
94+
Run linting and formatting checks using `ruff`:
95+
96+
```bash
97+
ruff check . # Check for linting issues
98+
ruff format --check . # Check code formatting
99+
ruff format . # Auto-format code
100+
```
101+
102+
Run type checking:
103+
104+
```bash
105+
mypy --ignore-missing-imports .
106+
```
107+
108+
### Project Structure
109+
110+
- `usbipd.py` - Main CLI entry point using `argparse`
111+
- `usb_device.py` - `USBDevice` wrapper class for `pyusb` device access
112+
- `usbip_server.py` - `USBIPServer` class implementing the USB/IP protocol
113+
- `binding_configuration.py` - `BindingConfiguration` class for XML-based device binding storage
114+
- `libusb_backend.py` - Cross-platform libusb backend loader for `pyusb`
115+
116+
### License
59117

60-
Note: Root privileges may be required to access USB devices on MacOS.
61-
4. Connect to the server from a client using USB/IP tools.
118+
Licensed under GPL-3.0. See [LICENSE](LICENSE) for details.

binding_configuration.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44

55
import os
66
import xml.etree.ElementTree as ET
7-
from typing import Optional
87
from xml.dom import minidom
98

109

@@ -13,7 +12,7 @@ class BindingConfiguration:
1312

1413
DEFAULT_CONFIG_PATH = os.path.expanduser("~/.config/usbipd/bindings.xml")
1514

16-
def __init__(self, config_path: Optional[str] = None) -> None:
15+
def __init__(self, config_path: str | None = None) -> None:
1716
"""
1817
Initialize the BindingConfiguration instance.
1918
@@ -135,9 +134,7 @@ def remove_binding(self, vendor_id: str, product_id: str, serial_number: str = "
135134

136135
return False
137136

138-
def get_binding(
139-
self, vendor_id: str, product_id: str, serial_number: str = ""
140-
) -> Optional[dict]:
137+
def get_binding(self, vendor_id: str, product_id: str, serial_number: str = "") -> dict | None:
141138
"""
142139
Get a specific binding by VID:PID:serial.
143140

pyproject.toml

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,18 @@ requires = ["setuptools>=61.0", "wheel", "setuptools-scm>=8.0"]
33
build-backend = "setuptools.build_meta"
44

55
[project]
6-
name = "usbipd-python"
6+
name = "usbipd"
77
dynamic = ["version"]
8-
description = "A USB/IP server for sharing USB devices over the network"
8+
description = "A Python USB/IP server for sharing USB devices over the network"
99
readme = "README.md"
10-
requires-python = ">=3.9"
10+
requires-python = ">=3.11"
1111
license = {text = "GPL-3.0"}
1212
authors = [
13-
{name = "abrinkman", email = "abrinkman@github.com"},
13+
{name = "Alexander Brinkman", email = "abrinkman@gmail.com"},
1414
]
1515
keywords = ["usb", "usbip", "network", "device-sharing"]
1616
classifiers = [
1717
"Programming Language :: Python :: 3",
18-
"Programming Language :: Python :: 3.9",
19-
"Programming Language :: Python :: 3.10",
2018
"Programming Language :: Python :: 3.11",
2119
"Programming Language :: Python :: 3.12",
2220
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
@@ -30,6 +28,14 @@ classifiers = [
3028

3129
dependencies = [
3230
"pyusb>=1.2.1",
31+
"libusb>=1.0.26",
32+
]
33+
34+
[project.optional-dependencies]
35+
dev = [
36+
"pip-tools>=7.0.0",
37+
"ruff>=0.14.0",
38+
"mypy>=1.0.0",
3339
]
3440

3541
[project.urls]
@@ -38,13 +44,13 @@ Repository = "https://github.com/abrinkman/usbipd-python.git"
3844
Issues = "https://github.com/abrinkman/usbipd-python/issues"
3945

4046
[project.scripts]
41-
usbipd-python = "usbipd:main"
47+
usbipd = "usbipd:main"
4248

4349
[tool.setuptools]
4450
py-modules = ["usbipd", "usb_device", "usbip_server", "binding_configuration", "libusb_backend"]
4551

4652
[tool.ruff]
47-
target-version = "py39"
53+
target-version = "py311"
4854
line-length = 100
4955

5056
[tool.ruff.lint]
@@ -55,11 +61,11 @@ ignore = [
5561
]
5662

5763
[tool.mypy]
58-
python_version = "3.9"
64+
python_version = "3.11"
5965
warn_return_any = true
6066
warn_unused_configs = true
6167
ignore_missing_imports = true
6268

6369
[tool.setuptools_scm]
6470
# Version is derived from git tags (e.g., v0.1.0 -> 0.1.0)
65-
# If no tag, generates dev version like 0.1.0.dev1+g1234567
71+
# If no tag, generates dev version like 0.1.0.dev1+g1234567

requirements-dev.txt

Lines changed: 0 additions & 3 deletions
This file was deleted.

requirements.txt

Lines changed: 0 additions & 2 deletions
This file was deleted.

usb_device.py

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99

1010
import logging
1111
import re
12-
from typing import Optional
1312

1413
import usb.core
1514
import usb.util
@@ -35,13 +34,13 @@ def __init__(self, device: usb.core.Device) -> None:
3534
"""
3635
self.device = device
3736
self.bus_id = self.build_bus_id(device)
38-
self._manufacturer: Optional[str] = None
39-
self._product: Optional[str] = None
40-
self._serial_number: Optional[str] = None
37+
self._manufacturer: str | None = None
38+
self._product: str | None = None
39+
self._serial_number: str | None = None
4140
self._strings_loaded = False
4241

4342
@staticmethod
44-
def clean_usb_string(value: Optional[str]) -> Optional[str]:
43+
def clean_usb_string(value: str | None) -> str | None:
4544
"""Clean a USB string by removing null characters and whitespace.
4645
4746
USB strings sometimes contain garbage data after null terminators.
@@ -143,19 +142,19 @@ def product_id(self) -> int:
143142
return int(self.device.idProduct)
144143

145144
@property
146-
def manufacturer(self) -> Optional[str]:
145+
def manufacturer(self) -> str | None:
147146
"""Get the manufacturer string of the device."""
148147
self._load_strings()
149148
return self._manufacturer
150149

151150
@property
152-
def product(self) -> Optional[str]:
151+
def product(self) -> str | None:
153152
"""Get the product string of the device."""
154153
self._load_strings()
155154
return self._product
156155

157156
@property
158-
def serial_number(self) -> Optional[str]:
157+
def serial_number(self) -> str | None:
159158
"""Get the serial number string of the device."""
160159
self._load_strings()
161160
return self._serial_number
@@ -168,7 +167,7 @@ def device_id(self) -> str:
168167
return f"{self.vendor_id:04x}:{self.product_id:04x}:{self._serial_number}"
169168
return f"{self.vendor_id:04x}:{self.product_id:04x}"
170169

171-
def to_dict(self) -> dict[str, Optional[str]]:
170+
def to_dict(self) -> Dict[str, Optional[str]]:
172171
"""Get basic device information as a dictionary.
173172
174173
Returns:
@@ -365,7 +364,7 @@ def list_devices(self) -> list[USBDevice]:
365364
devices = usb.core.find(find_all=True, backend=backend)
366365
return [USBDevice(device) for device in devices]
367366

368-
def find_by_bus_id(self, bus_id: str) -> Optional[USBDevice]:
367+
def find_by_bus_id(self, bus_id: str) -> USBDevice | None:
369368
"""Find a device by its bus ID.
370369
371370
Args:
@@ -402,8 +401,8 @@ def find_by_identity(
402401
self,
403402
vendor_id: int,
404403
product_id: int,
405-
serial_number: Optional[str] = None,
406-
) -> Optional[USBDevice]:
404+
serial_number: str | None = None,
405+
) -> USBDevice | None:
407406
"""Find a device by VID, PID, and optionally serial number.
408407
409408
Args:
@@ -435,7 +434,7 @@ def find_by_identity(
435434

436435
return None
437436

438-
def find_by_binding(self, binding: dict[str, str]) -> Optional[USBDevice]:
437+
def find_by_binding(self, binding: Dict[str, str]) -> Optional[USBDevice]:
439438
"""Find a device that matches a binding configuration.
440439
441440
The binding dictionary should contain 'vendor_id', 'product_id',

0 commit comments

Comments
 (0)