Skip to content

Commit ae1475e

Browse files
dccoteclaude
andcommitted
Add HIDPort transport and use it for PwrUSBDevice
Problem: PwrUSBDevice drove the strip only through USBPort (libusb). On macOS the OS IOHIDFamily driver claims the HID interface, so libusb cannot open it (USBError [Errno 13] Access denied) and there is no /dev node for SerialPort either. The strip was therefore unreachable on macOS. Solution: Add HIDPort, a CommunicationPort backed by hidapi (IOKit on macOS), alongside SerialPort and USBPort. Because it implements the same primitives (open/close/readData/writeData/flush), PwrUSBDevice's do* methods are unchanged; only transport selection is added: PwrUSBDevice(transport="hid"|"usb"|"auto"), default "auto" (HID when hidapi is importable, else USB). hidapi is optional, installed via the new pwrusb extra (pip install -e .[pwrusb]). Verified end to end on the lab unit (a PowerUSB "Basic", 04d8:003f): auto- selects HID, opens, and switches outlets 1/2/3 on and off. flush() drains with a bounded, strictly positive per-read timeout: hidapi blocks on timeout_ms=0 rather than returning, so a zero timeout would hang. The strip sends no unsolicited reports and answers a query with a 64-byte report whose leading byte(s) carry the datum; metering values are only meaningful on a metering- capable ("Smart") unit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9d8e8b4 commit ae1475e

6 files changed

Lines changed: 211 additions & 24 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,19 @@ API changes can land even when the minor version is unchanged.
1717
outlets 1-based), `DefaultOutletControl` (per-outlet power-on default state),
1818
and `CurrentMeteringControl` (`current()` in A, `accumulatedCharge()` in Ah,
1919
`resetAccumulatedCharge()`). The strip speaks a single-byte HID report protocol
20-
driven entirely through this library's `USBPort` (no HID/third-party dependency);
21-
the protocol was reverse-engineered publicly and cross-checked against
22-
aarossig/pwrusbctl (Apache-2.0) and pwrusb.com, but the implementation is our
23-
own. Outlet state is cached on write because live readback is unreliable on this
24-
firmware. Note: on macOS the OS `IOHIDFamily` driver claims the HID interface, so
25-
`initializeDevice` fails with `USBError [Errno 13] Access denied`; the driver is
26-
usable on Linux (with a udev rule granting access) and Windows.
20+
driven through a `CommunicationPort`; the protocol was reverse-engineered
21+
publicly and cross-checked against aarossig/pwrusbctl (Apache-2.0) and
22+
pwrusb.com, but the implementation is our own. Outlet state is cached on write
23+
because live readback is unreliable on this firmware.
24+
- `HIDPort` (`hardwarelibrary/communication/hidport.py`): a `CommunicationPort`
25+
over a USB HID device, backed by hidapi (IOKit on macOS), alongside
26+
`SerialPort` and `USBPort`. Needed because an HID device the OS claims has no
27+
`/dev` node (so `SerialPort` cannot reach it) and cannot be claimed by libusb
28+
(so `USBPort` cannot either) -- notably on macOS, where `IOHIDFamily` owns the
29+
interface. `PwrUSBDevice(transport=...)` selects the port: `"hid"` (HIDPort),
30+
`"usb"` (USBPort), or `"auto"` (HID when hidapi is importable, else USB, the
31+
default). hidapi is an optional dependency; install it with the new `pwrusb`
32+
extra (`pip install -e .[pwrusb]`), required to drive the strip on macOS.
2733
- `VerdiGDevice` (and `DebugVerdiGDevice`): a laser-source driver for the Coherent
2834
"HOPS" (High Output Power Supply) laser -- Genesis heads / Verdi G-C, e.g. the
2935
lab Genesis CX-Vis (head `G532`). A HOPS supply is not a serial device: its

hardwarelibrary/communication/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from .tcpport import TCPPort, UnableToOpenTCPPort
55
from .labviewtcpport import LabviewTCPPort
66
from .usbport import USBPort
7+
from .hidport import HIDPort
78
from .diagnostics import USBParameters, DeviceCommand, USBDeviceDescription
89
from .debugport import DebugPort, TableDrivenDebugPort
910
import usb.backend.libusb1
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
from .communicationport import CommunicationPort, CommunicationReadTimeout
2+
3+
4+
class HIDPort(CommunicationPort):
5+
"""A CommunicationPort over a USB HID device, backed by hidapi.
6+
7+
HID is the only way to reach a device the operating system claims as HID.
8+
On macOS the IOHIDFamily driver owns the interface, so neither SerialPort
9+
(there is no /dev node) nor USBPort (libusb cannot claim the interface) can
10+
open it; hidapi goes through the same IOKit HID manager the OS reserves.
11+
12+
This exposes the same primitives as SerialPort/USBPort (open/close/
13+
readData/writeData/flush), so a driver written against CommunicationPort
14+
works unchanged. It requires the optional 'hidapi' dependency (imported as
15+
'hid'); the import is deferred to open() so the rest of the library does not
16+
depend on it.
17+
18+
hidapi frames every report with the report ID as byte 0. Devices that use a
19+
single unnumbered report expect that byte to be 0x00; writeData prepends it,
20+
and the count it returns excludes it.
21+
"""
22+
23+
def __init__(self, idVendor, idProduct, serialNumber=None):
24+
super().__init__()
25+
self.idVendor = idVendor
26+
self.idProduct = idProduct
27+
self.serialNumber = serialNumber
28+
self.device = None
29+
self.defaultTimeout = 500
30+
self.reportSize = 64
31+
# Bounded drain for flush(): a small positive per-read timeout (never 0,
32+
# which blocks in hidapi rather than returning immediately) and a cap on
33+
# the number of reports drained, so flush can never hang.
34+
self.drainTimeout = 2
35+
self.maxDrainReports = 16
36+
self._internalBuffer = bytearray()
37+
38+
@property
39+
def isOpen(self) -> bool:
40+
return self.device is not None
41+
42+
def open(self):
43+
if self.isOpen:
44+
raise Exception("Port already open")
45+
46+
import hid
47+
self.device = hid.device()
48+
if self.serialNumber in (None, "*", ".*"):
49+
self.device.open(self.idVendor, self.idProduct)
50+
else:
51+
self.device.open(self.idVendor, self.idProduct, self.serialNumber)
52+
self._internalBuffer = bytearray()
53+
54+
def close(self):
55+
with self.portLock:
56+
if self.device is not None:
57+
self.device.close()
58+
self.device = None
59+
self._internalBuffer = bytearray()
60+
61+
def bytesAvailable(self, endPoint=None) -> int:
62+
with self.portLock:
63+
return len(self._internalBuffer)
64+
65+
def flush(self, endPoint=None):
66+
with self.portLock:
67+
self._internalBuffer = bytearray()
68+
if self.device is None:
69+
return
70+
# timeout_ms must stay positive: hidapi blocks on 0. The cap bounds
71+
# the wait even if a device were to stream input reports.
72+
for _ in range(self.maxDrainReports):
73+
if not self.device.read(self.reportSize, timeout_ms=self.drainTimeout):
74+
break
75+
76+
def readData(self, length, endPoint=None) -> bytearray:
77+
with self.portLock:
78+
while length > len(self._internalBuffer):
79+
chunk = self.device.read(self.reportSize, timeout_ms=self.defaultTimeout)
80+
if not chunk:
81+
raise CommunicationReadTimeout(
82+
"Read {0} of {1} requested bytes from HID device".format(
83+
len(self._internalBuffer), length))
84+
self._internalBuffer += bytearray(chunk)
85+
86+
data = self._internalBuffer[:length]
87+
self._internalBuffer = self._internalBuffer[length:]
88+
89+
return data
90+
91+
def writeData(self, data, endPoint=None) -> int:
92+
with self.portLock:
93+
# Byte 0 is the HID report ID (0x00 for a single, unnumbered report).
94+
written = self.device.write(bytes([0x00]) + bytes(data))
95+
if written < 0:
96+
raise IOError("Unable to write to HID device")
97+
98+
return max(0, written - 1)

hardwarelibrary/powerstrips/pwrusb.py

Lines changed: 56 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,17 +23,21 @@
2323
quirk: the read tends to return only the last port read), so the driver caches
2424
each outlet's state as it is written and reports the cache.
2525
26-
This driver depends only on this library's USBPort; it uses no HID library. The
27-
single-byte protocol above was reverse-engineered publicly; it was cross-checked
28-
against aarossig/pwrusbctl (Apache-2.0, https://github.com/aarossig/pwrusbctl)
29-
and pwrusb.com. The implementation here is our own.
26+
The driver talks to a CommunicationPort and selects the transport at init:
27+
HIDPort (hidapi/IOKit) or USBPort (libusb). On macOS the OS claims the HID
28+
interface, so HIDPort is required there (install the optional 'pwrusb' extra);
29+
USBPort works on Linux with a udev rule granting access. The single-byte
30+
protocol above was reverse-engineered publicly; it was cross-checked against
31+
aarossig/pwrusbctl (Apache-2.0, https://github.com/aarossig/pwrusbctl) and
32+
pwrusb.com. The implementation here is our own.
3033
"""
3134

3235
import usb.core
3336
import usb.util
3437

3538
from hardwarelibrary.physicaldevice import PhysicalDevice
3639
from hardwarelibrary.communication.usbport import USBPort
40+
from hardwarelibrary.communication.hidport import HIDPort
3741
from hardwarelibrary.communication.debugport import DebugPort
3842
from hardwarelibrary.powerstrips.powerstripdevice import PowerStripDevice
3943
from hardwarelibrary.powerstrips.capabilities import (
@@ -62,8 +66,12 @@ class PwrUSBDevice(PowerStripDevice, OutletSwitchingControl,
6266
switchableOutletCount = 3
6367

6468
def __init__(self, serialNumber: str = None, idProduct: int = None,
65-
idVendor: int = None, portPath: str = None):
69+
idVendor: int = None, portPath: str = None, transport: str = "auto"):
70+
# transport selects the port on real hardware: "hid" (hidapi/IOKit),
71+
# "usb" (USBPort/libusb), or "auto" (hid when hidapi is importable, else
72+
# usb). HID is required on macOS, where the OS claims the HID interface.
6673
self.portPath = portPath
74+
self.transport = transport
6775
self._outletStateCache = [False] * self.switchableOutletCount
6876
super().__init__(serialNumber=serialNumber, idProduct=idProduct, idVendor=idVendor)
6977
self.port = None
@@ -77,16 +85,15 @@ def doInitializeDevice(self):
7785
self.port = DebugPwrUSBPort()
7886
self.port.open()
7987
else:
80-
outputIndex, inputIndex = self._discoverEndpointIndices()
81-
self.port = USBPort(idVendor=self.idVendor, idProduct=self.idProduct,
82-
interfaceNumber=0,
83-
defaultEndPoints=(outputIndex, inputIndex))
88+
self.port = self._makePort()
8489
self.port.open()
85-
# USBPort.open() swallows I/O errors during its flush, so a HID
86-
# interface the OS will not release (e.g. macOS IOHIDFamily) is
87-
# only discovered on the first real transfer. Claim it here so
88-
# such a device fails to initialize cleanly instead of later.
89-
usb.util.claim_interface(self.port.device, 0)
90+
if isinstance(self.port, USBPort):
91+
# USBPort.open() swallows I/O errors during its flush, so a
92+
# HID interface the OS will not release (e.g. macOS
93+
# IOHIDFamily) is only discovered on the first real transfer.
94+
# Claim it here so such a device fails to initialize cleanly
95+
# instead of later.
96+
usb.util.claim_interface(self.port.device, 0)
9097

9198
self._outletStateCache = [False] * self.switchableOutletCount
9299
except Exception:
@@ -100,6 +107,41 @@ def doShutdownDevice(self):
100107
self.port.close()
101108
self.port = None
102109

110+
@staticmethod
111+
def _hidapiIsAvailable() -> bool:
112+
try:
113+
import hid # noqa: F401
114+
return True
115+
except ImportError:
116+
return False
117+
118+
def _makePort(self):
119+
"""Build the port for real hardware according to self.transport.
120+
121+
"hid" -> HIDPort (hidapi/IOKit), "usb" -> USBPort (libusb), "auto" ->
122+
HIDPort when hidapi is importable, else USBPort.
123+
"""
124+
transport = self.transport
125+
if transport == "auto":
126+
transport = "hid" if self._hidapiIsAvailable() else "usb"
127+
128+
if transport == "hid":
129+
if not self._hidapiIsAvailable():
130+
raise PhysicalDevice.UnableToInitialize(
131+
"transport='hid' needs the optional hidapi dependency: "
132+
"pip install -e .[pwrusb]")
133+
return HIDPort(idVendor=self.idVendor, idProduct=self.idProduct,
134+
serialNumber=self.serialNumber)
135+
136+
if transport == "usb":
137+
outputIndex, inputIndex = self._discoverEndpointIndices()
138+
return USBPort(idVendor=self.idVendor, idProduct=self.idProduct,
139+
interfaceNumber=0,
140+
defaultEndPoints=(outputIndex, inputIndex))
141+
142+
raise ValueError("transport must be 'auto', 'hid', or 'usb', got {0!r}".format(
143+
self.transport))
144+
103145
def _discoverEndpointIndices(self):
104146
"""Return (outputIndex, inputIndex): the positions of the interrupt OUT
105147
and IN endpoints within interface 0, as USBPort indexes them.

hardwarelibrary/tests/testPwrUSB.py

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import usb.core
55

66
from hardwarelibrary.physicaldevice import PhysicalDevice
7+
from hardwarelibrary.communication import HIDPort, USBPort
78
from hardwarelibrary.powerstrips import (
89
PwrUSBDevice, DebugPwrUSBDevice,
910
OutletSwitchingControl, DefaultOutletControl, CurrentMeteringControl,
@@ -88,6 +89,38 @@ def testAccumulatedChargeAndReset(self):
8889
self.assertAlmostEqual(self.device.accumulatedCharge(), 0.0)
8990

9091

92+
class TestPwrUSBTransportSelection(unittest.TestCase):
93+
"""Transport picking is pure logic; exercise it without hardware by
94+
stubbing the hidapi-availability and endpoint-discovery helpers."""
95+
96+
def testInvalidTransportRaises(self):
97+
device = PwrUSBDevice(transport="bogus")
98+
with self.assertRaises(ValueError):
99+
device._makePort()
100+
101+
def testHidTransportWithoutHidapiRaises(self):
102+
device = PwrUSBDevice(transport="hid")
103+
device._hidapiIsAvailable = lambda: False
104+
with self.assertRaises(PhysicalDevice.UnableToInitialize):
105+
device._makePort()
106+
107+
def testHidTransportBuildsHIDPort(self):
108+
device = PwrUSBDevice(transport="hid")
109+
device._hidapiIsAvailable = lambda: True
110+
self.assertIsInstance(device._makePort(), HIDPort)
111+
112+
def testAutoPrefersHidWhenAvailable(self):
113+
device = PwrUSBDevice(transport="auto")
114+
device._hidapiIsAvailable = lambda: True
115+
self.assertIsInstance(device._makePort(), HIDPort)
116+
117+
def testAutoFallsBackToUsbWithoutHidapi(self):
118+
device = PwrUSBDevice(transport="auto")
119+
device._hidapiIsAvailable = lambda: False
120+
device._discoverEndpointIndices = lambda: (0, 1)
121+
self.assertIsInstance(device._makePort(), USBPort)
122+
123+
91124
class TestPwrUSBDevice(unittest.TestCase):
92125
def setUp(self):
93126
super().setUp()
@@ -96,9 +129,10 @@ def setUp(self):
96129
self.device.initializeDevice()
97130
except PhysicalDevice.UnableToInitialize as error:
98131
cause = error.args[0] if error.args else None
99-
# No strip attached (or no libusb backend) surfaces as a USBError or
100-
# IOError from the port. There is no hardware to exercise, so skip.
101-
if isinstance(cause, (usb.core.USBError, usb.core.NoBackendError, IOError)):
132+
# No strip attached surfaces as a USBError/NoBackendError (USBPort)
133+
# or an OSError/IOError (HIDPort or a missing libusb backend). There
134+
# is no hardware to exercise, so skip rather than fail.
135+
if isinstance(cause, (usb.core.USBError, usb.core.NoBackendError, OSError)):
102136
self.skipTest("No PwrUSB connected")
103137
raise
104138

pyproject.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,12 @@ thorlabs = [
5656
# Backend for ThorlabsKinesisDevice (the Thorlabs Kinesis runtime wrapper).
5757
"pylablib",
5858
]
59+
pwrusb = [
60+
# HID transport (HIDPort) for PwrUSBDevice. Imports as `hid`. Required to
61+
# reach the strip on macOS, where the OS claims the HID interface and neither
62+
# SerialPort nor USBPort/libusb can open it.
63+
"hidapi",
64+
]
5965

6066
[project.urls]
6167
Homepage = "https://github.com/DCC-Lab/PyHardwareLibrary"

0 commit comments

Comments
 (0)