Skip to main content
Glama
README.md
# devusb

[![CI](https://github.com/JacobBeningo/devusb/actions/workflows/ci.yml/badge.svg)](https://github.com/JacobBeningo/devusb/actions/workflows/ci.yml)

Agent-friendly USB power control for the embedded bench, built for the
Agentic Firmware Workbench. Switch, monitor, and power-cycle USB devices —
from the command line or from an AI agent via MCP.

Typical use: an agent flashing and testing firmware hits a hung debug probe or
wedged dev board. Instead of asking a human to unplug it, the agent runs
`power_cycle` and carries on.

## Supported hardware

devusb drives two kinds of hub through one interface.

**Yepkit YKUSH family** — dedicated bench hardware, spoken to directly over
USB HID (`hidapi`). No vendor utility needed. Addressed by serial number.

| Hub | Switched ports | Status |
|-----|----------------|--------|
| YKUSH3 | 3 | verified on hardware |
| YKUSH | 3 | same protocol, untested |
| YKUSH XS | 1 | same protocol, untested |

**Generic power-switchable hubs** — any hub advertising per-port power
switching (`ppps`), driven through
[uhubctl](https://github.com/mvp/uhubctl). Many ordinary hubs and docks
qualify, so this often works with hardware you already own. Addressed by
uhubctl *location* (e.g. `35-2.1.4`), since ordinary hubs rarely carry
unique serial numbers. Verified on Realtek RTS5411 and GenesysLogic hubs.

## Install

```bash
pip install .
```

Requires Python 3.10+. For generic hub support, also install uhubctl:

```bash
brew install uhubctl        # macOS
sudo apt install uhubctl    # Debian/Ubuntu
```

YKUSH control never depends on uhubctl — if it is missing, generic hubs are
simply absent from the listing.

### Linux: udev rules

On Linux, non-root access to the hub's HID interface needs a udev rule:

```bash
sudo cp udev/99-devusb.rules /etc/udev/rules.d/
sudo udevadm control --reload-rules && sudo udevadm trigger
```

Then unplug and replug the hub. macOS and Windows need no extra setup.

## CLI

```bash
devusb list                      # every controllable hub, both backends
devusb list --ykush-only         # skip generic hubs
devusb status                    # power state of each port
devusb off 1                     # cut power to port 1
devusb on 1                      # restore power
devusb cycle 1 --hold 10         # power-cycle: off 10 s, back on
devusb on all                    # every port
devusb --json status             # machine-readable output
```

`--hub` picks which hub to talk to — a YKUSH serial or a generic hub
location:

```bash
devusb --hub Y3N14127 status     # YKUSH by serial
devusb --hub 35-2.1.4 status     # generic hub by location
devusb --hub 35-2.1.4 cycle 2    # cycle a port on that hub
```

With exactly one YKUSH attached, `--hub` is optional. **Generic hubs must
always be named**: they share the listing with the rest of the machine, so
devusb will not pick one for you.

## Safety

Cutting power to the wrong port has real consequences — an unmounted disk, a
dark monitor, a dropped network link. Two rules apply everywhere:

- Never cut power to a device mid-flash.
- After power returns, give the device a few seconds to re-enumerate before
  talking to it.

### The protected-port guard

Generic hubs get an extra guard, because a typical machine lists a lot of
hubs that have nothing to do with the bench. devusb refuses to switch off a
port whose downstream subtree contains storage, a display, input hardware, or
networking:

```console
$ devusb --hub 35-4.1 off 1
error: refusing to cut power to 35-4.1 port 1: network device attached
(0bda:8153 Realtek USB 10/100/1000 LAN 1010000CD). Pass force to override.
```

The check follows the whole subtree, not just the directly attached device —
cutting a port kills everything below it, including devices on a downstream
hub. `devusb list` marks guarded ports, and `devusb status` shows what is
attached to each port so you can see what a port number really means:

```console
$ devusb --hub 35-4.1 status
port 1: on  [0bda:8153 Realtek USB 10/100/1000 LAN 1010000CD]  (protected)
port 2: on  [05e3:0620 GenesysLogic USB3.2 Hub, USB 3.20, 4 ports, ppps]  (protected)
port 3: on
port 4: on  [0bda:0411 Generic 4-Port USB 3.0 Hub, USB 3.00, 4 ports, ppps]
```

Pass `--force` (CLI) or `force=True` (MCP) to override. Detection is
best-effort keyword matching on what uhubctl reports, so treat it as a
seatbelt, not a proof: check `status` before switching an unfamiliar port.

YKUSH hubs have no guard — every port is a dedicated bench port.

### Interoperability with uhubctl

The YKUSH3 is built on Microchip hub silicon (USB5744/USB2744) that also
supports generic per-port power switching, so a YKUSH can appear under *both*
backends. Don't drive the same hub both ways: the YKUSH firmware doesn't
track changes made behind its back, and port numbering differs between the
two paths (e.g. YKUSH port 1 is internal hub port 3). Pick one controller per
hub — prefer the `ykush` backend, which is purpose-built.

## MCP server

`devusb-mcp` runs an MCP server over stdio exposing `list_hubs`,
`port_status`, `port_on`, `port_off`, and `power_cycle` — with agent-facing
safety guidance baked into the tool descriptions, including the instruction
to call `port_status` before switching a generic hub port and to confirm with
the user before forcing past the guard.

Register with Claude Code:

```bash
claude mcp add --scope user devusb devusb-mcp
```

Any MCP-capable agent host works the same way — point it at the
`devusb-mcp` executable.

## Roadmap

- **Acroname programmable hubs** — the premium lab option.
- **Paired USB 2.0 / 3.x port switching** for generic hubs: a physical port
  often appears as separate hub instances per speed, and cutting only one
  leaves the device half-powered. uhubctl handles this pairing; devusb
  currently exposes each instance separately.

## Development

```bash
pip install -e ".[dev]"
pytest
```

The suite runs against fake HID and uhubctl layers — no hardware required.
The uhubctl fake renders real uhubctl output so the parser is exercised.

Before pushing, run it once with the host's tools hidden:

```bash
env PATH=/usr/bin:/bin "$(command -v python3)" -m pytest -q
```

The fakes are meant to be hermetic, but a developer machine has uhubctl
installed and CI does not — so a test that quietly reaches past the fake to
the real binary still passes locally and fails in CI. Stripping PATH catches
that before you push. (`$(command -v python3)` resolves the interpreter
*before* PATH is emptied; a bare `pytest` would no longer be findable.)

## License

MIT