From 0561c62e06b3ae91c7fdafff011182c031968bf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20C=C3=B4t=C3=A9?= Date: Wed, 22 Jul 2026 18:52:39 -0400 Subject: [PATCH] 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) --- .claude/skills/pyhardwarelibrary/SKILL.md | 12 ++- CHANGELOG.md | 9 +- hardwarelibrary/capabilities.py | 99 +++++++++++++++++++ hardwarelibrary/powerstrips/__init__.py | 5 +- hardwarelibrary/powerstrips/capabilities.py | 99 ------------------- .../powerstrips/powerstripdevice.py | 24 +---- hardwarelibrary/powerstrips/pwrusb.py | 8 +- hardwarelibrary/tests/testPwrUSB.py | 12 +-- 8 files changed, 130 insertions(+), 138 deletions(-) delete mode 100644 hardwarelibrary/powerstrips/capabilities.py diff --git a/.claude/skills/pyhardwarelibrary/SKILL.md b/.claude/skills/pyhardwarelibrary/SKILL.md index 721882b..9fe94e6 100644 --- a/.claude/skills/pyhardwarelibrary/SKILL.md +++ b/.claude/skills/pyhardwarelibrary/SKILL.md @@ -10,6 +10,12 @@ subclass with a uniform lifecycle and a small, predictable public API per family skill is for *using* devices to get work done (move, measure, set). To *write a new driver*, read `CLAUDE.md` and `README-4-New-device-coding-example.md` instead. +**Developers: always pull `master` from origin before starting.** This repository evolves +rapidly and its architecture shifts under you — capability mixins were recently renamed +(`*Control` -> `*Capability`) and consolidated into a single `hardwarelibrary/capabilities.py`. +Branch off an up-to-date `master` (`git pull` first) so you build against the current +conventions, not a stale snapshot. + **Use the primitives from `CommunicationPort` for all communications.** A driver's `do*` methods talk to hardware only through `self.port` — `writeData` / `readData` (and the `readString` / `writeString` / `writeStringReadMatch` helpers built on them). Do **not** @@ -202,9 +208,9 @@ it declares. | Capability | Methods | |---|---| -| `OutletSwitchingControl` | `turnOutletOn(n)`, `turnOutletOff(n)`, `setOutletState(n, isOn)`, `isOutletOn(n)`, `outletCount` | -| `DefaultOutletControl` | `setOutletDefaultOn(n)`, `setOutletDefaultOff(n)`, `setOutletDefaultState(n, isOn)` | -| `CurrentMeteringControl` | `current()` (A), `accumulatedCharge()` (Ah), `resetAccumulatedCharge()` | +| `OutletSwitchingCapability` | `turnOutletOn(n)`, `turnOutletOff(n)`, `setOutletState(n, isOn)`, `isOutletOn(n)`, `outletCount` | +| `DefaultOutletCapability` | `setOutletDefaultOn(n)`, `setOutletDefaultOff(n)`, `setOutletDefaultState(n, isOn)` | +| `CurrentMeteringCapability` | `current()` (A), `accumulatedCharge()` (Ah), `resetAccumulatedCharge()` | ```python from hardwarelibrary.powerstrips import PwrUSBDevice diff --git a/CHANGELOG.md b/CHANGELOG.md index a91b5dd..e12158b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,11 +12,12 @@ API changes can land even when the minor version is unchanged. power strip (USB HID `04d8:003f`, enumerates as "Simple HID Device Demo"). The family follows the interface-segregated capability-mixin pattern used by `sources/` and `daq/`: `PowerStripDevice` is a thin marker base over - `PhysicalDevice`, and behaviour comes from `OutletSwitchingControl` + `PhysicalDevice`, and behaviour comes from `OutletSwitchingCapability` (`turnOutletOn`/`turnOutletOff`/`setOutletState`/`isOutletOn`/`outletCount`, - outlets 1-based), `DefaultOutletControl` (per-outlet power-on default state), - and `CurrentMeteringControl` (`current()` in A, `accumulatedCharge()` in Ah, - `resetAccumulatedCharge()`). The strip speaks a single-byte HID report protocol + outlets 1-based), `DefaultOutletCapability` (per-outlet power-on default + state), and `CurrentMeteringCapability` (`current()` in A, `accumulatedCharge()` + in Ah, `resetAccumulatedCharge()`), all in the shared + `hardwarelibrary/capabilities.py`. The strip speaks a single-byte HID report protocol driven through a `HIDPort`; the protocol was reverse-engineered publicly and cross-checked against aarossig/pwrusbctl (Apache-2.0) and pwrusb.com, but the implementation is our own. Outlet state is cached on write because live diff --git a/hardwarelibrary/capabilities.py b/hardwarelibrary/capabilities.py index f197da5..c874984 100644 --- a/hardwarelibrary/capabilities.py +++ b/hardwarelibrary/capabilities.py @@ -576,3 +576,102 @@ def getDigitalDirection(self, channel): def setDigitalDirection(self, channel): pass + + +# --------------------------------------------------------------------------- +# Power strip capabilities +# --------------------------------------------------------------------------- + + +class OutletSwitchingCapability(Capability): + """Switch individual outlets on and off and read their state. + + Outlets are addressed by their physical label (1-based): the first + switchable outlet is outlet 1. Some strips also carry an always-on outlet + that is not switchable and is not counted here. + """ + + def turnOutletOn(self, outlet: int): + self.doSetOutletState(outlet, True) + + def turnOutletOff(self, outlet: int): + self.doSetOutletState(outlet, False) + + def setOutletState(self, outlet: int, isOn: bool): + self.doSetOutletState(outlet, isOn) + + def isOutletOn(self, outlet: int) -> bool: + return self.doGetOutletState(outlet) + + @property + def outletCount(self) -> int: + return self.doGetOutletCount() + + @abstractmethod + def doSetOutletState(self, outlet: int, isOn: bool): + ... + + @abstractmethod + def doGetOutletState(self, outlet: int) -> bool: + ... + + @abstractmethod + def doGetOutletCount(self) -> int: + ... + + +class DefaultOutletCapability(Capability): + """Set the power-on (boot) state of individual outlets. + + Distinct from OutletSwitchingCapability: this configures the state each + outlet powers up in after the strip loses and regains mains power, not its + state right now. + """ + + def setOutletDefaultOn(self, outlet: int): + self.doSetOutletDefaultState(outlet, True) + + def setOutletDefaultOff(self, outlet: int): + self.doSetOutletDefaultState(outlet, False) + + def setOutletDefaultState(self, outlet: int, isOn: bool): + self.doSetOutletDefaultState(outlet, isOn) + + @abstractmethod + def doSetOutletDefaultState(self, outlet: int, isOn: bool): + ... + + +class CurrentMeteringCapability(Capability): + """Measure the strip's total current draw and accumulated charge. + + Only metering-capable strips (e.g. the PowerUSB "Smart" model) implement + this; a driver mixes it in only when the hardware supports it. Values are in + SI units at this boundary: current in amperes, accumulated charge in + ampere-hours. + """ + + unit = "A" + isReadable = True + isWritable = False + + def current(self) -> float: + return self.doGetCurrent() + + def accumulatedCharge(self) -> float: + return self.doGetAccumulatedCharge() + + def resetAccumulatedCharge(self): + self.doResetAccumulatedCharge() + + @abstractmethod + def doGetCurrent(self) -> float: + ... + + @abstractmethod + def doGetAccumulatedCharge(self) -> float: + ... + + @abstractmethod + def doResetAccumulatedCharge(self): + ... diff --git a/hardwarelibrary/powerstrips/__init__.py b/hardwarelibrary/powerstrips/__init__.py index 95c6121..307ad57 100644 --- a/hardwarelibrary/powerstrips/__init__.py +++ b/hardwarelibrary/powerstrips/__init__.py @@ -1,6 +1,5 @@ -from .capabilities import ( - Capability, - OutletSwitchingControl, DefaultOutletControl, CurrentMeteringControl, +from hardwarelibrary.capabilities import ( + OutletSwitchingCapability, DefaultOutletCapability, CurrentMeteringCapability, ) from .powerstripdevice import PowerStripDevice from .pwrusb import PwrUSBDevice, DebugPwrUSBDevice, DebugPwrUSBPort diff --git a/hardwarelibrary/powerstrips/capabilities.py b/hardwarelibrary/powerstrips/capabilities.py deleted file mode 100644 index 9df781f..0000000 --- a/hardwarelibrary/powerstrips/capabilities.py +++ /dev/null @@ -1,99 +0,0 @@ -from abc import ABC, abstractmethod - - -class Capability(ABC): - pass - - -class OutletSwitchingControl(Capability): - """Switch individual outlets on and off and read their state. - - Outlets are addressed by their physical label (1-based): the first - switchable outlet is outlet 1. Some strips also carry an always-on outlet - that is not switchable and is not counted here. - """ - - def turnOutletOn(self, outlet: int): - self.doSetOutletState(outlet, True) - - def turnOutletOff(self, outlet: int): - self.doSetOutletState(outlet, False) - - def setOutletState(self, outlet: int, isOn: bool): - self.doSetOutletState(outlet, isOn) - - def isOutletOn(self, outlet: int) -> bool: - return self.doGetOutletState(outlet) - - @property - def outletCount(self) -> int: - return self.doGetOutletCount() - - @abstractmethod - def doSetOutletState(self, outlet: int, isOn: bool): - ... - - @abstractmethod - def doGetOutletState(self, outlet: int) -> bool: - ... - - @abstractmethod - def doGetOutletCount(self) -> int: - ... - - -class DefaultOutletControl(Capability): - """Set the power-on (boot) state of individual outlets. - - Distinct from OutletSwitchingControl: this configures the state each outlet - powers up in after the strip loses and regains mains power, not its state - right now. - """ - - def setOutletDefaultOn(self, outlet: int): - self.doSetOutletDefaultState(outlet, True) - - def setOutletDefaultOff(self, outlet: int): - self.doSetOutletDefaultState(outlet, False) - - def setOutletDefaultState(self, outlet: int, isOn: bool): - self.doSetOutletDefaultState(outlet, isOn) - - @abstractmethod - def doSetOutletDefaultState(self, outlet: int, isOn: bool): - ... - - -class CurrentMeteringControl(Capability): - """Measure the strip's total current draw and accumulated charge. - - Only metering-capable strips (e.g. the PowerUSB "Smart" model) implement - this; a driver mixes it in only when the hardware supports it. Values are in - SI units at this boundary: current in amperes, accumulated charge in - ampere-hours. - """ - - unit = "A" - isReadable = True - isWritable = False - - def current(self) -> float: - return self.doGetCurrent() - - def accumulatedCharge(self) -> float: - return self.doGetAccumulatedCharge() - - def resetAccumulatedCharge(self): - self.doResetAccumulatedCharge() - - @abstractmethod - def doGetCurrent(self) -> float: - ... - - @abstractmethod - def doGetAccumulatedCharge(self) -> float: - ... - - @abstractmethod - def doResetAccumulatedCharge(self): - ... diff --git a/hardwarelibrary/powerstrips/powerstripdevice.py b/hardwarelibrary/powerstrips/powerstripdevice.py index 1d754a8..38a2fe1 100644 --- a/hardwarelibrary/powerstrips/powerstripdevice.py +++ b/hardwarelibrary/powerstrips/powerstripdevice.py @@ -1,23 +1,9 @@ from hardwarelibrary.physicaldevice import PhysicalDevice -from hardwarelibrary.powerstrips.capabilities import Capability class PowerStripDevice(PhysicalDevice): - """Family marker base for controllable power strips (switched outlets). - - A driver subclasses this and mixes in the capability classes from - capabilities.py (OutletSwitchingControl, DefaultOutletControl, - CurrentMeteringControl). The behaviour lives in the mixins; this base only - reports which capabilities a given driver actually implements. - """ - - def capabilities(self) -> list: - # The capability mixins, not the marker nor the device class itself - # (a driver is a Capability subclass too, but it is a PhysicalDevice). - return [klass for klass in type(self).__mro__ - if issubclass(klass, Capability) - and klass is not Capability - and not issubclass(klass, PhysicalDevice)] - - def hasCapability(self, capabilityClass) -> bool: - return isinstance(self, capabilityClass) + # A thin marker base for controllable power strips (switched outlets). The + # behavior comes from the *Capability mixins a driver combines with it; + # capability introspection (capabilities() / hasCapability()) is inherited + # from PhysicalDevice. + pass diff --git a/hardwarelibrary/powerstrips/pwrusb.py b/hardwarelibrary/powerstrips/pwrusb.py index 95bc83c..e26b1a7 100644 --- a/hardwarelibrary/powerstrips/pwrusb.py +++ b/hardwarelibrary/powerstrips/pwrusb.py @@ -34,13 +34,13 @@ from hardwarelibrary.communication.hidport import HIDPort from hardwarelibrary.communication.debugport import DebugPort from hardwarelibrary.powerstrips.powerstripdevice import PowerStripDevice -from hardwarelibrary.powerstrips.capabilities import ( - OutletSwitchingControl, DefaultOutletControl, CurrentMeteringControl, +from hardwarelibrary.capabilities import ( + OutletSwitchingCapability, DefaultOutletCapability, CurrentMeteringCapability, ) -class PwrUSBDevice(PowerStripDevice, OutletSwitchingControl, - DefaultOutletControl, CurrentMeteringControl): +class PwrUSBDevice(PowerStripDevice, OutletSwitchingCapability, + DefaultOutletCapability, CurrentMeteringCapability): classIdVendor = 0x04d8 classIdProduct = 0x003f diff --git a/hardwarelibrary/tests/testPwrUSB.py b/hardwarelibrary/tests/testPwrUSB.py index 1fe9502..5123455 100644 --- a/hardwarelibrary/tests/testPwrUSB.py +++ b/hardwarelibrary/tests/testPwrUSB.py @@ -4,7 +4,7 @@ from hardwarelibrary.physicaldevice import PhysicalDevice from hardwarelibrary.powerstrips import ( PwrUSBDevice, DebugPwrUSBDevice, - OutletSwitchingControl, DefaultOutletControl, CurrentMeteringControl, + OutletSwitchingCapability, DefaultOutletCapability, CurrentMeteringCapability, ) @@ -29,13 +29,13 @@ def testOutletCount(self): def testCapabilities(self): capabilities = self.device.capabilities() - self.assertIn(OutletSwitchingControl, capabilities) - self.assertIn(DefaultOutletControl, capabilities) - self.assertIn(CurrentMeteringControl, capabilities) + self.assertIn(OutletSwitchingCapability, capabilities) + self.assertIn(DefaultOutletCapability, capabilities) + self.assertIn(CurrentMeteringCapability, capabilities) def testHasCapability(self): - self.assertTrue(self.device.hasCapability(OutletSwitchingControl)) - self.assertTrue(self.device.hasCapability(CurrentMeteringControl)) + self.assertTrue(self.device.hasCapability(OutletSwitchingCapability)) + self.assertTrue(self.device.hasCapability(CurrentMeteringCapability)) def testAllOutletsStartOff(self): for outlet in (1, 2, 3):