Skip to content

Commit 0561c62

Browse files
dccoteclaude
andcommitted
Align PwrUSB power strip with the consolidated *Capability architecture
Problem: The PwrUSB family (PR #115) was branched before master consolidated all capability mixins into hardwarelibrary/capabilities.py and renamed them *Control -> *Capability. The clean merge left the power strip on the old convention: a separate hardwarelibrary/powerstrips/capabilities.py with its own Capability base and OutletSwitchingControl/DefaultOutletControl/ CurrentMeteringControl names, plus a duplicate capabilities()/hasCapability() on PowerStripDevice. It imported and passed tests but was inconsistent with the rest of the library, and mixins subclassed a different Capability base than the canonical one (so isinstance against hardwarelibrary.capabilities found nothing). Solution: Move the three mixins into the shared hardwarelibrary/capabilities.py as OutletSwitchingCapability, DefaultOutletCapability, and CurrentMeteringCapability (canonical Capability base); delete powerstrips/capabilities.py. Reduce PowerStripDevice to a pure marker like LaserSourceDevice, inheriting capabilities()/hasCapability() from PhysicalDevice. Update pwrusb.py bases/imports, the package __init__ re-export, tests, the skill doc, and the CHANGELOG entry accordingly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent df99465 commit 0561c62

8 files changed

Lines changed: 130 additions & 138 deletions

File tree

.claude/skills/pyhardwarelibrary/SKILL.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ 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+
**Developers: always pull `master` from origin before starting.** This repository evolves
14+
rapidly and its architecture shifts under you — capability mixins were recently renamed
15+
(`*Control` -> `*Capability`) and consolidated into a single `hardwarelibrary/capabilities.py`.
16+
Branch off an up-to-date `master` (`git pull` first) so you build against the current
17+
conventions, not a stale snapshot.
18+
1319
**Use the primitives from `CommunicationPort` for all communications.** A driver's `do*`
1420
methods talk to hardware only through `self.port``writeData` / `readData` (and the
1521
`readString` / `writeString` / `writeStringReadMatch` helpers built on them). Do **not**
@@ -202,9 +208,9 @@ it declares.
202208

203209
| Capability | Methods |
204210
|---|---|
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()` |
211+
| `OutletSwitchingCapability` | `turnOutletOn(n)`, `turnOutletOff(n)`, `setOutletState(n, isOn)`, `isOutletOn(n)`, `outletCount` |
212+
| `DefaultOutletCapability` | `setOutletDefaultOn(n)`, `setOutletDefaultOff(n)`, `setOutletDefaultState(n, isOn)` |
213+
| `CurrentMeteringCapability` | `current()` (A), `accumulatedCharge()` (Ah), `resetAccumulatedCharge()` |
208214

209215
```python
210216
from hardwarelibrary.powerstrips import PwrUSBDevice

CHANGELOG.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,12 @@ API changes can land even when the minor version is unchanged.
1212
power strip (USB HID `04d8:003f`, enumerates as "Simple HID Device Demo"). The
1313
family follows the interface-segregated capability-mixin pattern used by
1414
`sources/` and `daq/`: `PowerStripDevice` is a thin marker base over
15-
`PhysicalDevice`, and behaviour comes from `OutletSwitchingControl`
15+
`PhysicalDevice`, and behaviour comes from `OutletSwitchingCapability`
1616
(`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
17+
outlets 1-based), `DefaultOutletCapability` (per-outlet power-on default
18+
state), and `CurrentMeteringCapability` (`current()` in A, `accumulatedCharge()`
19+
in Ah, `resetAccumulatedCharge()`), all in the shared
20+
`hardwarelibrary/capabilities.py`. The strip speaks a single-byte HID report protocol
2021
driven through a `HIDPort`; the protocol was reverse-engineered publicly and
2122
cross-checked against aarossig/pwrusbctl (Apache-2.0) and pwrusb.com, but the
2223
implementation is our own. Outlet state is cached on write because live

hardwarelibrary/capabilities.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -576,3 +576,102 @@ def getDigitalDirection(self, channel):
576576

577577
def setDigitalDirection(self, channel):
578578
pass
579+
580+
581+
# ---------------------------------------------------------------------------
582+
# Power strip capabilities
583+
# ---------------------------------------------------------------------------
584+
585+
586+
class OutletSwitchingCapability(Capability):
587+
"""Switch individual outlets on and off and read their state.
588+
589+
Outlets are addressed by their physical label (1-based): the first
590+
switchable outlet is outlet 1. Some strips also carry an always-on outlet
591+
that is not switchable and is not counted here.
592+
"""
593+
594+
def turnOutletOn(self, outlet: int):
595+
self.doSetOutletState(outlet, True)
596+
597+
def turnOutletOff(self, outlet: int):
598+
self.doSetOutletState(outlet, False)
599+
600+
def setOutletState(self, outlet: int, isOn: bool):
601+
self.doSetOutletState(outlet, isOn)
602+
603+
def isOutletOn(self, outlet: int) -> bool:
604+
return self.doGetOutletState(outlet)
605+
606+
@property
607+
def outletCount(self) -> int:
608+
return self.doGetOutletCount()
609+
610+
@abstractmethod
611+
def doSetOutletState(self, outlet: int, isOn: bool):
612+
...
613+
614+
@abstractmethod
615+
def doGetOutletState(self, outlet: int) -> bool:
616+
...
617+
618+
@abstractmethod
619+
def doGetOutletCount(self) -> int:
620+
...
621+
622+
623+
class DefaultOutletCapability(Capability):
624+
"""Set the power-on (boot) state of individual outlets.
625+
626+
Distinct from OutletSwitchingCapability: this configures the state each
627+
outlet powers up in after the strip loses and regains mains power, not its
628+
state right now.
629+
"""
630+
631+
def setOutletDefaultOn(self, outlet: int):
632+
self.doSetOutletDefaultState(outlet, True)
633+
634+
def setOutletDefaultOff(self, outlet: int):
635+
self.doSetOutletDefaultState(outlet, False)
636+
637+
def setOutletDefaultState(self, outlet: int, isOn: bool):
638+
self.doSetOutletDefaultState(outlet, isOn)
639+
640+
@abstractmethod
641+
def doSetOutletDefaultState(self, outlet: int, isOn: bool):
642+
...
643+
644+
645+
class CurrentMeteringCapability(Capability):
646+
"""Measure the strip's total current draw and accumulated charge.
647+
648+
Only metering-capable strips (e.g. the PowerUSB "Smart" model) implement
649+
this; a driver mixes it in only when the hardware supports it. Values are in
650+
SI units at this boundary: current in amperes, accumulated charge in
651+
ampere-hours.
652+
"""
653+
654+
unit = "A"
655+
isReadable = True
656+
isWritable = False
657+
658+
def current(self) -> float:
659+
return self.doGetCurrent()
660+
661+
def accumulatedCharge(self) -> float:
662+
return self.doGetAccumulatedCharge()
663+
664+
def resetAccumulatedCharge(self):
665+
self.doResetAccumulatedCharge()
666+
667+
@abstractmethod
668+
def doGetCurrent(self) -> float:
669+
...
670+
671+
@abstractmethod
672+
def doGetAccumulatedCharge(self) -> float:
673+
...
674+
675+
@abstractmethod
676+
def doResetAccumulatedCharge(self):
677+
...
Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
from .capabilities import (
2-
Capability,
3-
OutletSwitchingControl, DefaultOutletControl, CurrentMeteringControl,
1+
from hardwarelibrary.capabilities import (
2+
OutletSwitchingCapability, DefaultOutletCapability, CurrentMeteringCapability,
43
)
54
from .powerstripdevice import PowerStripDevice
65
from .pwrusb import PwrUSBDevice, DebugPwrUSBDevice, DebugPwrUSBPort

hardwarelibrary/powerstrips/capabilities.py

Lines changed: 0 additions & 99 deletions
This file was deleted.
Lines changed: 5 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,9 @@
11
from hardwarelibrary.physicaldevice import PhysicalDevice
2-
from hardwarelibrary.powerstrips.capabilities import Capability
32

43

54
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)
5+
# A thin marker base for controllable power strips (switched outlets). The
6+
# behavior comes from the *Capability mixins a driver combines with it;
7+
# capability introspection (capabilities() / hasCapability()) is inherited
8+
# from PhysicalDevice.
9+
pass

hardwarelibrary/powerstrips/pwrusb.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,13 @@
3434
from hardwarelibrary.communication.hidport import HIDPort
3535
from hardwarelibrary.communication.debugport import DebugPort
3636
from hardwarelibrary.powerstrips.powerstripdevice import PowerStripDevice
37-
from hardwarelibrary.powerstrips.capabilities import (
38-
OutletSwitchingControl, DefaultOutletControl, CurrentMeteringControl,
37+
from hardwarelibrary.capabilities import (
38+
OutletSwitchingCapability, DefaultOutletCapability, CurrentMeteringCapability,
3939
)
4040

4141

42-
class PwrUSBDevice(PowerStripDevice, OutletSwitchingControl,
43-
DefaultOutletControl, CurrentMeteringControl):
42+
class PwrUSBDevice(PowerStripDevice, OutletSwitchingCapability,
43+
DefaultOutletCapability, CurrentMeteringCapability):
4444
classIdVendor = 0x04d8
4545
classIdProduct = 0x003f
4646

hardwarelibrary/tests/testPwrUSB.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from hardwarelibrary.physicaldevice import PhysicalDevice
55
from hardwarelibrary.powerstrips import (
66
PwrUSBDevice, DebugPwrUSBDevice,
7-
OutletSwitchingControl, DefaultOutletControl, CurrentMeteringControl,
7+
OutletSwitchingCapability, DefaultOutletCapability, CurrentMeteringCapability,
88
)
99

1010

@@ -29,13 +29,13 @@ def testOutletCount(self):
2929

3030
def testCapabilities(self):
3131
capabilities = self.device.capabilities()
32-
self.assertIn(OutletSwitchingControl, capabilities)
33-
self.assertIn(DefaultOutletControl, capabilities)
34-
self.assertIn(CurrentMeteringControl, capabilities)
32+
self.assertIn(OutletSwitchingCapability, capabilities)
33+
self.assertIn(DefaultOutletCapability, capabilities)
34+
self.assertIn(CurrentMeteringCapability, capabilities)
3535

3636
def testHasCapability(self):
37-
self.assertTrue(self.device.hasCapability(OutletSwitchingControl))
38-
self.assertTrue(self.device.hasCapability(CurrentMeteringControl))
37+
self.assertTrue(self.device.hasCapability(OutletSwitchingCapability))
38+
self.assertTrue(self.device.hasCapability(CurrentMeteringCapability))
3939

4040
def testAllOutletsStartOff(self):
4141
for outlet in (1, 2, 3):

0 commit comments

Comments
 (0)