Skip to content

Commit df99465

Browse files
authored
Merge pull request #115 from DCC-Lab/pwrusb-power-strip
Add PowerStrip device family, PwrUSBDevice driver, and HIDPort transport
2 parents 163e202 + 2467a24 commit df99465

10 files changed

Lines changed: 629 additions & 1 deletion

File tree

.claude/skills/pyhardwarelibrary/SKILL.md

Lines changed: 44 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
@@ -10,6 +10,15 @@ subclass with a uniform lifecycle and a small, predictable public API per family
1010
skill is for *using* devices to get work done (move, measure, set). To *write a new
1111
driver*, read `CLAUDE.md` and `README-4-New-device-coding-example.md` instead.
1212

13+
**Use the primitives from `CommunicationPort` for all communications.** A driver's `do*`
14+
methods talk to hardware only through `self.port``writeData` / `readData` (and the
15+
`readString` / `writeString` / `writeStringReadMatch` helpers built on them). Do **not**
16+
invent new send/receive helpers on the device (no `_sendCommandByte`, no `_query`); call
17+
the port methods directly. To support a new transport (serial, USB, HID, TCP, ...),
18+
subclass `CommunicationPort` and go through the expected mechanism of **overriding
19+
`readData` and `writeData`** (plus `open` / `close` / `isOpen` / `flush`) — the string and
20+
transaction helpers then compose on top for free. `HIDPort` is the reference example.
21+
1322
## The one rule: lifecycle
1423

1524
Every device follows the same pattern. Construct it, initialize it, use it, shut it down.
@@ -185,6 +194,40 @@ daq.setDigitalValue(1, channel=5)
185194
daq.shutdownDevice()
186195
```
187196

197+
### Power strips — PwrUSB / PowerUSB
198+
199+
Base: `PowerStripDevice` plus capability mixins (like lasers/DAQ). Outlets are addressed
200+
**1-based** to match the physical labels. A device only has the methods of the capabilities
201+
it declares.
202+
203+
| Capability | Methods |
204+
|---|---|
205+
| `OutletSwitchingControl` | `turnOutletOn(n)`, `turnOutletOff(n)`, `setOutletState(n, isOn)`, `isOutletOn(n)`, `outletCount` |
206+
| `DefaultOutletControl` | `setOutletDefaultOn(n)`, `setOutletDefaultOff(n)`, `setOutletDefaultState(n, isOn)` |
207+
| `CurrentMeteringControl` | `current()` (A), `accumulatedCharge()` (Ah), `resetAccumulatedCharge()` |
208+
209+
```python
210+
from hardwarelibrary.powerstrips import PwrUSBDevice
211+
212+
strip = PwrUSBDevice() # first strip found (over hidapi)
213+
strip.initializeDevice()
214+
strip.turnOutletOn(1) # outlets are 1, 2, 3
215+
print(strip.isOutletOn(1), "of", strip.outletCount)
216+
strip.setOutletDefaultOff(1) # boot state after mains power returns
217+
strip.turnOutletOff(1)
218+
strip.shutdownDevice()
219+
```
220+
221+
- **USB HID only** (`04d8:003f`), driven over hidapi (`HIDPort`). The OS claims the HID
222+
interface (on macOS `SerialPort` has no `/dev` node and `USBPort`/libusb can't open it),
223+
so control needs the hidapi extra: `pip install -e .[pwrusb]` (PyPI package `hidapi`,
224+
imports as `hid`*not* the different `hid` package).
225+
- **Outlet state is cached on write** (live readback is unreliable on this firmware), so
226+
`isOutletOn(n)` returns the last commanded state.
227+
- **Metering is only meaningful on the "Smart" model**; a "Basic" unit returns
228+
non-measurement values from `current()` / `accumulatedCharge()`.
229+
- No hardware? `DebugPwrUSBDevice()` emulates the protocol in memory.
230+
188231
### Oscilloscopes — Tektronix TDS
189232

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

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,27 @@ 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 `HIDPort`; the protocol was reverse-engineered publicly and
21+
cross-checked against aarossig/pwrusbctl (Apache-2.0) and pwrusb.com, but the
22+
implementation is our own. Outlet state is cached on write because live
23+
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. hidapi is an optional dependency; install it with the new `pwrusb`
30+
extra (`pip install -e .[pwrusb]`), required to drive the strip.
1031
- `VerdiGDevice` (and `DebugVerdiGDevice`): a laser-source driver for the Coherent
1132
"HOPS" (High Output Power Supply) laser -- Genesis heads / Verdi G-C, e.g. the
1233
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)