Skip to content

Commit 5779eb7

Browse files
dccoteclaude
andcommitted
Add PowerStrip device family, PwrUSBDevice driver, and HIDPort transport
Problem: A PwrUSB / PowerUSB power bar (USB HID 04d8:003f, enumerates as "Simple HID Device Demo") with three switchable outlets was attached, but PyHardwareLibrary had no power-strip family or driver for it, and no way to reach an HID device the OS claims (on macOS, IOHIDFamily owns the interface, so neither SerialPort nor USBPort/libusb can open it). Solution: Add hardwarelibrary/powerstrips/ following the interface-segregated capability-mixin pattern of sources/ and daq/. PowerStripDevice is a thin marker base over PhysicalDevice exposing capabilities()/hasCapability(); capabilities.py defines OutletSwitchingControl, DefaultOutletControl, and CurrentMeteringControl, each a public method over a do* hook. PwrUSBDevice implements all three, speaking the device's single-byte HID report protocol through a CommunicationPort. Outlet state is cached on write because live readback is unreliable on this firmware. DebugPwrUSBPort decodes the real command bytes so DebugPwrUSBDevice exercises the encoding end to end without hardware. Add HIDPort, a CommunicationPort backed by hidapi (IOKit on macOS), alongside SerialPort and USBPort. Because it implements the same primitives, the driver is transport-agnostic: 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]); the package is hidapi (cython-hidapi, imports as hid), not the different hid package. HIDPort.flush drains with a strictly positive per-read timeout, since hidapi blocks on timeout_ms=0. The single-byte protocol was reverse-engineered publicly and cross-checked against aarossig/pwrusbctl (Apache-2.0) and pwrusb.com; the implementation is our own (attribution in the module docstring and CHANGELOG). Verified end to end on the lab unit (a PowerUSB "Basic"): auto-selects HID, opens, and switches outlets 1/2/3 on and off. Metering is only meaningful on a "Smart" unit. Documents the new family in the pyhardwarelibrary skill. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ce03dde commit 5779eb7

10 files changed

Lines changed: 734 additions & 1 deletion

File tree

.claude/skills/pyhardwarelibrary/SKILL.md

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
name: pyhardwarelibrary
3-
description: Control lab hardware with the PyHardwareLibrary Python package — motion stages, spectrometers, lasers, power meters, DAQs, and oscilloscopes. Use when a user wants to move a stage, acquire a spectrum, turn a laser on/off or set its power/wavelength, read a power meter, read/write DAQ voltages, discover connected devices, run code without hardware (debug devices), or build a headless/GUI controller around a device. Covers the device lifecycle, per-family public API, event notifications, and the DeviceController worker-thread wrapper.
3+
description: Control lab hardware with the PyHardwareLibrary Python package — motion stages, spectrometers, lasers, power meters, DAQs, oscilloscopes, and USB power strips. Use when a user wants to move a stage, acquire a spectrum, turn a laser on/off or set its power/wavelength, read a power meter, read/write DAQ voltages, switch power-strip outlets on/off, discover connected devices, run code without hardware (debug devices), or build a headless/GUI controller around a device. Covers the device lifecycle, per-family public API, event notifications, and the DeviceController worker-thread wrapper.
44
---
55

66
# Using PyHardwareLibrary
@@ -185,6 +185,41 @@ daq.setDigitalValue(1, channel=5)
185185
daq.shutdownDevice()
186186
```
187187

188+
### Power strips — PwrUSB / PowerUSB
189+
190+
Base: `PowerStripDevice` plus capability mixins (like lasers/DAQ). Outlets are addressed
191+
**1-based** to match the physical labels. A device only has the methods of the capabilities
192+
it declares.
193+
194+
| Capability | Methods |
195+
|---|---|
196+
| `OutletSwitchingControl` | `turnOutletOn(n)`, `turnOutletOff(n)`, `setOutletState(n, isOn)`, `isOutletOn(n)`, `outletCount` |
197+
| `DefaultOutletControl` | `setOutletDefaultOn(n)`, `setOutletDefaultOff(n)`, `setOutletDefaultState(n, isOn)` |
198+
| `CurrentMeteringControl` | `current()` (A), `accumulatedCharge()` (Ah), `resetAccumulatedCharge()` |
199+
200+
```python
201+
from hardwarelibrary.powerstrips import PwrUSBDevice
202+
203+
strip = PwrUSBDevice() # transport="auto"; first strip found
204+
strip.initializeDevice()
205+
strip.turnOutletOn(1) # outlets are 1, 2, 3
206+
print(strip.isOutletOn(1), "of", strip.outletCount)
207+
strip.setOutletDefaultOff(1) # boot state after mains power returns
208+
strip.turnOutletOff(1)
209+
strip.shutdownDevice()
210+
```
211+
212+
- **USB HID only** (`04d8:003f`). On macOS the OS claims the HID interface, so control
213+
needs the hidapi extra: `pip install -e .[pwrusb]` (PyPI package `hidapi`, imports as
214+
`hid`*not* the different `hid` package). `PwrUSBDevice(transport="auto")` uses the
215+
HID port when hidapi is importable, else `USBPort` (Linux, with a udev rule granting
216+
access); force one with `transport="hid"` / `transport="usb"`.
217+
- **Outlet state is cached on write** (live readback is unreliable on this firmware), so
218+
`isOutletOn(n)` returns the last commanded state.
219+
- **Metering is only meaningful on the "Smart" model**; a "Basic" unit returns
220+
non-measurement values from `current()` / `accumulatedCharge()`.
221+
- No hardware? `DebugPwrUSBDevice()` emulates the protocol in memory.
222+
188223
### Oscilloscopes — Tektronix TDS
189224

190225
Instantiated directly (no family/driver split); methods are SCPI per instrument.

CHANGELOG.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,29 @@ API changes can land even when the minor version is unchanged.
77
## [Unreleased]
88

99
### Added
10+
- PowerStrip device family (`hardwarelibrary/powerstrips/`) plus its first driver
11+
`PwrUSBDevice` (and `DebugPwrUSBDevice`) for the PwrUSB / PowerUSB controllable
12+
power strip (USB HID `04d8:003f`, enumerates as "Simple HID Device Demo"). The
13+
family follows the interface-segregated capability-mixin pattern used by
14+
`sources/` and `daq/`: `PowerStripDevice` is a thin marker base over
15+
`PhysicalDevice`, and behaviour comes from `OutletSwitchingControl`
16+
(`turnOutletOn`/`turnOutletOff`/`setOutletState`/`isOutletOn`/`outletCount`,
17+
outlets 1-based), `DefaultOutletControl` (per-outlet power-on default state),
18+
and `CurrentMeteringControl` (`current()` in A, `accumulatedCharge()` in Ah,
19+
`resetAccumulatedCharge()`). The strip speaks a single-byte HID report protocol
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.
1033
- `VerdiGDevice` (and `DebugVerdiGDevice`): a laser-source driver for the Coherent
1134
"HOPS" (High Output Power Supply) laser -- Genesis heads / Verdi G-C, e.g. the
1235
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)
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from .capabilities import (
2+
Capability,
3+
OutletSwitchingControl, DefaultOutletControl, CurrentMeteringControl,
4+
)
5+
from .powerstripdevice import PowerStripDevice
6+
from .pwrusb import PwrUSBDevice, DebugPwrUSBDevice, DebugPwrUSBPort
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
from abc import ABC, abstractmethod
2+
3+
4+
class Capability(ABC):
5+
pass
6+
7+
8+
class OutletSwitchingControl(Capability):
9+
"""Switch individual outlets on and off and read their state.
10+
11+
Outlets are addressed by their physical label (1-based): the first
12+
switchable outlet is outlet 1. Some strips also carry an always-on outlet
13+
that is not switchable and is not counted here.
14+
"""
15+
16+
def turnOutletOn(self, outlet: int):
17+
self.doSetOutletState(outlet, True)
18+
19+
def turnOutletOff(self, outlet: int):
20+
self.doSetOutletState(outlet, False)
21+
22+
def setOutletState(self, outlet: int, isOn: bool):
23+
self.doSetOutletState(outlet, isOn)
24+
25+
def isOutletOn(self, outlet: int) -> bool:
26+
return self.doGetOutletState(outlet)
27+
28+
@property
29+
def outletCount(self) -> int:
30+
return self.doGetOutletCount()
31+
32+
@abstractmethod
33+
def doSetOutletState(self, outlet: int, isOn: bool):
34+
...
35+
36+
@abstractmethod
37+
def doGetOutletState(self, outlet: int) -> bool:
38+
...
39+
40+
@abstractmethod
41+
def doGetOutletCount(self) -> int:
42+
...
43+
44+
45+
class DefaultOutletControl(Capability):
46+
"""Set the power-on (boot) state of individual outlets.
47+
48+
Distinct from OutletSwitchingControl: this configures the state each outlet
49+
powers up in after the strip loses and regains mains power, not its state
50+
right now.
51+
"""
52+
53+
def setOutletDefaultOn(self, outlet: int):
54+
self.doSetOutletDefaultState(outlet, True)
55+
56+
def setOutletDefaultOff(self, outlet: int):
57+
self.doSetOutletDefaultState(outlet, False)
58+
59+
def setOutletDefaultState(self, outlet: int, isOn: bool):
60+
self.doSetOutletDefaultState(outlet, isOn)
61+
62+
@abstractmethod
63+
def doSetOutletDefaultState(self, outlet: int, isOn: bool):
64+
...
65+
66+
67+
class CurrentMeteringControl(Capability):
68+
"""Measure the strip's total current draw and accumulated charge.
69+
70+
Only metering-capable strips (e.g. the PowerUSB "Smart" model) implement
71+
this; a driver mixes it in only when the hardware supports it. Values are in
72+
SI units at this boundary: current in amperes, accumulated charge in
73+
ampere-hours.
74+
"""
75+
76+
unit = "A"
77+
isReadable = True
78+
isWritable = False
79+
80+
def current(self) -> float:
81+
return self.doGetCurrent()
82+
83+
def accumulatedCharge(self) -> float:
84+
return self.doGetAccumulatedCharge()
85+
86+
def resetAccumulatedCharge(self):
87+
self.doResetAccumulatedCharge()
88+
89+
@abstractmethod
90+
def doGetCurrent(self) -> float:
91+
...
92+
93+
@abstractmethod
94+
def doGetAccumulatedCharge(self) -> float:
95+
...
96+
97+
@abstractmethod
98+
def doResetAccumulatedCharge(self):
99+
...
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from hardwarelibrary.physicaldevice import PhysicalDevice
2+
from hardwarelibrary.powerstrips.capabilities import Capability
3+
4+
5+
class PowerStripDevice(PhysicalDevice):
6+
"""Family marker base for controllable power strips (switched outlets).
7+
8+
A driver subclasses this and mixes in the capability classes from
9+
capabilities.py (OutletSwitchingControl, DefaultOutletControl,
10+
CurrentMeteringControl). The behaviour lives in the mixins; this base only
11+
reports which capabilities a given driver actually implements.
12+
"""
13+
14+
def capabilities(self) -> list:
15+
# The capability mixins, not the marker nor the device class itself
16+
# (a driver is a Capability subclass too, but it is a PhysicalDevice).
17+
return [klass for klass in type(self).__mro__
18+
if issubclass(klass, Capability)
19+
and klass is not Capability
20+
and not issubclass(klass, PhysicalDevice)]
21+
22+
def hasCapability(self, capabilityClass) -> bool:
23+
return isinstance(self, capabilityClass)

0 commit comments

Comments
 (0)