Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions .claude/skills/pyhardwarelibrary/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down Expand Up @@ -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
Expand Down
9 changes: 5 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
99 changes: 99 additions & 0 deletions hardwarelibrary/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
...
5 changes: 2 additions & 3 deletions hardwarelibrary/powerstrips/__init__.py
Original file line number Diff line number Diff line change
@@ -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
99 changes: 0 additions & 99 deletions hardwarelibrary/powerstrips/capabilities.py

This file was deleted.

24 changes: 5 additions & 19 deletions hardwarelibrary/powerstrips/powerstripdevice.py
Original file line number Diff line number Diff line change
@@ -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
8 changes: 4 additions & 4 deletions hardwarelibrary/powerstrips/pwrusb.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 6 additions & 6 deletions hardwarelibrary/tests/testPwrUSB.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from hardwarelibrary.physicaldevice import PhysicalDevice
from hardwarelibrary.powerstrips import (
PwrUSBDevice, DebugPwrUSBDevice,
OutletSwitchingControl, DefaultOutletControl, CurrentMeteringControl,
OutletSwitchingCapability, DefaultOutletCapability, CurrentMeteringCapability,
)


Expand All @@ -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):
Expand Down