Skip to content

Commit 9d8e8b4

Browse files
dccoteclaude
andcommitted
Add PowerStrip device family and PwrUSBDevice driver
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. 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 entirely through this library's USBPort (no HID or third-party dependency). The protocol was reverse-engineered publicly and cross-checked against aarossig/pwrusbctl (Apache-2.0) and pwrusb.com; the implementation is our own, with attribution in the module docstring and CHANGELOG. 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. On macOS the OS IOHIDFamily driver claims the HID interface, so initializeDevice fails cleanly with USBError (hardware tests skip per the project's no-hardware policy); the driver is usable on Linux/Windows. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ce03dde commit 9d8e8b4

6 files changed

Lines changed: 520 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,23 @@ 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 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.
1027
- `VerdiGDevice` (and `DebugVerdiGDevice`): a laser-source driver for the Coherent
1128
"HOPS" (High Output Power Supply) laser -- Genesis heads / Verdi G-C, e.g. the
1229
lab Genesis CX-Vis (head `G532`). A HOPS supply is not a serial device: its
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)