uhubctl in a Docker container with an API for port power control
349
A small Go HTTP service that wraps uhubctl to switch individual USB hub ports on, off, or power-cycle them. It works with any hub uhubctl supports, including USB3 hubs. It runs in a non-privileged container that can see only the hub's own device nodes.
The examples here use a Plugable 7-port USB3 hub plugged into a Pi's USB 2.0 port. On a USB 2.0 port only the hub's USB 2.0 side connects, and it shows up as two cascaded 4-port VIA Labs hubs (2109:2817):
1-1.2 Dev 007 VIA hub A ports 1, 2, 4 = external sockets, port 3 = hub B
1-1.2.3 Dev 008 VIA hub B ports 1-4 = external sockets
port 1: Fire TV Stick (Dev 012)
port 2: Fire TV Stick (Dev 013)
port 4: Fire TV Stick (Dev 014)
A port is addressed as <hub-location>:<port>. For example, the first Fire TV Stick is 1-1.2.3:1. Port 1-1.2:3 feeds hub B. The service refuses to switch it, because switching it off would drop four sockets off the bus and change hub B's device number.
Fire TV Sticks show up on the bus (as Lab126 1949:06cc), but onn. 4K sticks don't, probably because they draw only power from the port. Switching still works for them: the hub cuts and restores the port's power whether or not a device enumerates. An onn. stick's port just reports connected: false.
To match physical sockets to location:port, plug in a USB device that enumerates and run lsusb -t.
uhubctl talks to a hub with USB control requests through the hub's usbfs node (/dev/bus/usb/BBB/DDD). The container gets:
devices:. The UPS, the flash drive and the streaming sticks are not visible to it./sys read-only (Docker's default). libusb uses it to enumerate devices and locations, and the service reads the names of the devices on each port from it.A hub's device node, /dev/bus/usb/BBB/DDD, is numbered in the order devices show up, so it can change on every reboot or re-plug. On the Pi used here, a reboot moved the hubs from 001/007 and 001/008 to 001/004 and 001/007, and 001/008 went to a flash drive. Passing the numbers into the container directly would break, or pass in the wrong device.
A udev rule on the Docker host instead gives every hub a fixed name based on its location, the physical path from the Pi, like /dev/usbhub/1-1.2. The container copies a script that installs the rule to ${HOST_DIR}/uhubctl on the host. Run it once (with HOST_DIR=/data):
sudo sh /data/uhubctl/usbhub-udev.sh
Wrote /etc/udev/rules.d/99-usbhub.rules
USB hubs (stable name -> current node, ID, name):
/dev/usbhub/1-1 -> ../bus/usb/001/002 0424:2514
/dev/usbhub/1-1.1 -> ../bus/usb/001/003 0424:2514
/dev/usbhub/1-1.2 -> ../bus/usb/001/004 2109:2817 USB2.0 Hub
/dev/usbhub/1-1.2.3 -> ../bus/usb/001/007 2109:2817 USB2.0 Hub
/dev/usbhub/usb1 -> ../bus/usb/001/001 1d6b:0002 DWC OTG Controller
Hubs built into the Pi are listed too; find yours by its ID or name. For the Plugable, the shorter location is the hub plugged into the Pi, and the longer one is the cascaded hub on its port 3. The settings are:
HUB_DEVICE_1=/dev/usbhub/1-1.2
HUB_DEVICE_2=/dev/usbhub/1-1.2.3
HUBS=1-1.2,1-1.2.3
Docker resolves /dev/usbhub/... to the hub's current node each time the container starts, and the service makes it available at the /dev/bus/usb/BBB/DDD path uhubctl opens. After a reboot nothing needs changing. If you re-plug the hub while the Pi is running, restart the container. Until then, /readyz reports the hub as unreachable.
A hub's location only changes if it moves to a different socket. Then update HUB_DEVICE_n and HUBS, along with PORT_NAMES.
Without the udev rule you can still set HUB_DEVICE_n to /dev/bus/usb/BBB/DDD directly, but then you have to check the numbers after every reboot.
A USB3 hub is really two hubs: a USB 2.0 side and a SuperSpeed (USB 3) side. On a USB 2.0 port, like the Pi 3's, only the USB 2.0 side connects. On a USB3 port (e.g. the blue ports on a Pi 4 or 5), the SuperSpeed side also shows up, as more hubs on a second bus. For VIA Labs hubs it's typically ID 2109:0817. A port only loses power when both sides switch it off. uhubctl does that automatically, but only if the SuperSpeed hubs are passed into the container too.
usbhub-udev.sh lists both sides, for example:
/dev/usbhub/1-1.2 -> ../bus/usb/001/004 2109:2817 USB2.0 Hub
/dev/usbhub/1-1.2.3 -> ../bus/usb/001/007 2109:2817 USB2.0 Hub
/dev/usbhub/2-1 -> ../bus/usb/002/002 2109:0817 USB3.0 Hub
/dev/usbhub/2-1.3 -> ../bus/usb/002/003 2109:0817 USB3.0 Hub
Pass the SuperSpeed hubs in with HUB_DEVICE_3 and HUB_DEVICE_4. Keep HUBS and PORT_NAMES on the USB 2.0 locations, and uhubctl switches the matching SuperSpeed port along with each one:
HUB_DEVICE_3=/dev/usbhub/2-1
HUB_DEVICE_4=/dev/usbhub/2-1.3
If the SuperSpeed side isn't passed in, the service refuses to switch that hub. /readyz and the logs name the hub to add, e.g. set HUB_DEVICE_3 or HUB_DEVICE_4 to /dev/usbhub/2-1. In /api/v1/hubs, each hub's companion field shows its SuperSpeed side.
Locations differ between USB 2.0 and USB3 ports, so if the hub moves to a different port, update HUBS and PORT_NAMES as well.
In Portainer, go to Stacks → Add stack → Web editor:
HUB_DEVICE_1 and HUB_DEVICE_2 lines for now. They default to /dev/null, so the container can start without them.udev script ready.HUB_DEVICE_1, HUB_DEVICE_2 and HUBS from its output, and redeploy.linked hub node and hub reachable for both hubs, and curl -s <host>:7610/readyz should report ok for each hub.All settings are environment variables. Set them in the stack's Environment variables section in Portainer.
| Variable | Default | Meaning |
|---|---|---|
HUBS | (required) | Comma-separated hub locations that may be queried or switched. See Giving your hubs stable names. |
HUB_DEVICE_1, HUB_DEVICE_2 | (required) | Hubs passed into the container, by their stable name (/dev/usbhub/<location>). |
HUB_DEVICE_3, HUB_DEVICE_4 | USB3 ports only: the hubs' SuperSpeed side. See On a USB3 port. | |
PORT_NAMES | Friendly names, e.g. firestick1=1-1.2.3:1,firestick2=1-1.2.3:2 | |
PROTECTED_PORTS | Extra ports that must never be switched, e.g. 1-1.2:1,1-1.2:4. The port feeding a cascaded hub in HUBS is protected automatically. | |
HOST_PORT | 7610 | Host port. |
HOST_DIR | /data | Parent directory on the host. The container copies usbhub-udev.sh to ${HOST_DIR}/uhubctl. |
TAG | latest | Image tag to run. |
CONTAINER_NAME | uhubctl | Container name. |
All responses are JSON. Actions use POST. uhubctl calls run one at a time, and a power cycle always runs to completion, even if the client disconnects.
There is no authentication: anyone who can reach the port can switch the hub's ports, so keep it on your LAN.
| Method & path | Description |
|---|---|
GET /healthz | Liveness check. Doesn't touch USB. |
GET /readyz | Runs uhubctl against every hub. Returns 503 if any hub can't be reached. |
GET /api/v1/hubs | Status of all configured hubs and their ports. |
GET /api/v1/hubs/{hub} | Status of one hub, e.g. /api/v1/hubs/1-1.2.3. |
GET /api/v1/hubs/{hub}/ports/{port} | Status of one port. |
POST /api/v1/hubs/{hub}/ports/{port}/{on|off|cycle} | Switch a port. cycle accepts ?delay=<seconds>. |
GET /api/v1/ports | Status of all named ports. |
GET /api/v1/ports/{name} | Status of a named port. |
POST /api/v1/ports/{name}/{on|off|cycle} | Switch a named port. |
Status of all named ports:
curl -s localhost:7610/api/v1/ports
[
{
"name": "firestick1",
"hub": "1-1.2.3",
"port": 1,
"power": true,
"connected": true,
"status": "0503",
"flags": ["power", "highspeed", "enable", "connect"],
"device": "1949:06cc Lab126, Inc. AFTKRT G000XXXX"
},
{
"name": "firestick2",
"hub": "1-1.2.3",
"port": 2,
"power": false,
"connected": false,
"status": "0000",
"flags": ["off"]
}
]
power is whether the port is switched on. connected is whether a device has enumerated on it, so it stays false for power-only devices like onn. 4K sticks. device (vendor:product, maker, model and serial, read from /sys) appears only when a device has enumerated. If a hub can't be read, its named ports come back with an error field instead of the status fields.
Switching a port:
curl -s -X POST localhost:7610/api/v1/ports/firestick1/off
curl -s -X POST "localhost:7610/api/v1/ports/firestick1/cycle?delay=5"
curl -s -X POST localhost:7610/api/v1/hubs/1-1.2/ports/1/on
An action returns the port's new status, plus the action it ran:
{
"name": "firestick1",
"hub": "1-1.2.3",
"port": 1,
"action": "off",
"power": false,
"connected": false,
"status": "0000",
"flags": ["off"]
}
Status codes: 400 bad action, port or delay · 403 protected port · 404 unknown hub, port or name · 502 uhubctl failed (the error message contains its output).
No compatible devices detected at location …: the hub was re-plugged while the container was running. Restart the container. If you set HUB_DEVICE_n to /dev/bus/usb/BBB/DDD numbers instead of stable names, look up the current numbers.error gathering device information … /dev/usbhub/…: no such file or directory: the udev rule isn't installed (run usbhub-udev.sh), the hub isn't plugged in, or it's in a different socket than HUB_DEVICE_n says. Check ls -l /dev/usbhub/.off: some hub and kernel combinations re-enable ports. cycle is unaffected. uhubctl first tries the kernel's sysfs disable file for the port (/sys/bus/usb/devices/<hub>/<hub>:1.0/<hub>-port<n>/disable, kernel 6.0+), which keeps the port off. With /sys read-only it falls back to a plain hub request, and prints a harmless Failed to set port status by writing to …disable (Read-only file system) message. Making off stick would mean giving the container write access to those files. That hasn't been tested here, and it gives the container write access to that part of sysfs.description in GET /api/v1/hubs ends in ppps (per-port power switching), not ganged.Current status for hub … line, or nothing: libusb 1.0.27 and older crash during cleanup in this container setup, after the status has been read. The image uses an Alpine release with a fixed libusb. If you build your own, check apk info -v libusb.Content type
Image
Digest
sha256:e1dec1a22…
Size
6.3 MB
Last updated
about 10 hours ago
docker pull bnhf/uhubctl