From eef9642dff676284ce00e2381e4c2647d02e9bdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20C=C3=B4t=C3=A9?= Date: Mon, 6 Jul 2026 15:11:33 -0400 Subject: [PATCH 1/2] Add capability mixins to the power-meter family Problem: PowerMeterDevice baked wavelength-calibration hooks directly into the base class even though not every meter calibrates by wavelength, and there was no way to introspect what a given meter can do. The laser-source family already solves this with interface-segregated Capability mixins, but the power meters did not mirror that structure. Solution: Introduce hardwarelibrary/powermeters/capabilities.py with a Capability ABC and three orthogonal mixins matching the sources/ pattern: WavelengthCalibratable (wavelength hooks moved out of the base), AutoScalable (toggle automatic ranging), and ScaleAdjustable (read/set the manual full-scale range and enumerate available scales). PowerMeterDevice now keeps only the always-present doGetAbsolutePower hook and gains capabilities()/hasCapability() introspection copied from LaserSourceDevice. IntegraDevice and FieldMasterDevice declare WavelengthCalibratable; their existing hooks satisfy the contract unchanged. Adds TestFieldMasterCapabilities covering the introspection API. Co-Authored-By: Claude Opus 4.8 (1M context) --- hardwarelibrary/powermeters/__init__.py | 1 + hardwarelibrary/powermeters/capabilities.py | 86 +++++++++++++++++++ .../powermeters/fieldmasterdevice.py | 3 +- hardwarelibrary/powermeters/integradevice.py | 3 +- .../powermeters/powermeterdevice.py | 31 +++---- .../tests/testFieldMasterDevice.py | 25 ++++++ 6 files changed, 129 insertions(+), 20 deletions(-) create mode 100644 hardwarelibrary/powermeters/capabilities.py diff --git a/hardwarelibrary/powermeters/__init__.py b/hardwarelibrary/powermeters/__init__.py index 4e11738..265a596 100644 --- a/hardwarelibrary/powermeters/__init__.py +++ b/hardwarelibrary/powermeters/__init__.py @@ -1,4 +1,5 @@ +from .capabilities import Capability, WavelengthCalibratable, AutoScalable, ScaleAdjustable from .powermeterdevice import PowerMeterDevice from .integradevice import IntegraDevice from .fieldmasterdevice import FieldMasterDevice, DebugFieldMasterDevice diff --git a/hardwarelibrary/powermeters/capabilities.py b/hardwarelibrary/powermeters/capabilities.py new file mode 100644 index 0000000..9e52e3a --- /dev/null +++ b/hardwarelibrary/powermeters/capabilities.py @@ -0,0 +1,86 @@ +from abc import ABC, abstractmethod + + +class Capability(ABC): + pass + + +class WavelengthCalibratable(Capability): + unit = "nm" + isReadable = True + isWritable = True + calibrationWavelength = None + + def getCalibrationWavelength(self): + self.doGetCalibrationWavelength() + return self.calibrationWavelength + + def setCalibrationWavelength(self, wavelength): + self.doSetCalibrationWavelength(wavelength) + self.doGetCalibrationWavelength() + + @abstractmethod + def doGetCalibrationWavelength(self): + ... + + @abstractmethod + def doSetCalibrationWavelength(self, wavelength): + ... + + +class AutoScalable(Capability): + # The meter picks its measurement range automatically when auto-scaling is + # on; turning it off pins the range to whatever scale is active. A meter may + # also expose ScaleAdjustable to choose that range by hand. + def autoScaleIsOn(self) -> bool: + return self.doGetAutoScale() + + def turnAutoScaleOn(self): + self.doTurnAutoScaleOn() + + def turnAutoScaleOff(self): + self.doTurnAutoScaleOff() + + @abstractmethod + def doGetAutoScale(self) -> bool: + ... + + @abstractmethod + def doTurnAutoScaleOn(self): + ... + + @abstractmethod + def doTurnAutoScaleOff(self): + ... + + +class ScaleAdjustable(Capability): + # The full-scale measurement range (e.g. 200e-3 W). Independent of + # AutoScalable: setting a scale by hand generally requires auto-scaling off. + unit = "W" + isReadable = True + isWritable = True + scale = None + + def getScale(self): + self.doGetScale() + return self.scale + + def setScale(self, scale): + self.doSetScale(scale) + self.doGetScale() + + def availableScales(self) -> list: + return self.doGetAvailableScales() + + @abstractmethod + def doGetScale(self): + ... + + @abstractmethod + def doSetScale(self, scale): + ... + + @abstractmethod + def doGetAvailableScales(self) -> list: + ... diff --git a/hardwarelibrary/powermeters/fieldmasterdevice.py b/hardwarelibrary/powermeters/fieldmasterdevice.py index 5471212..535935f 100644 --- a/hardwarelibrary/powermeters/fieldmasterdevice.py +++ b/hardwarelibrary/powermeters/fieldmasterdevice.py @@ -7,11 +7,12 @@ ) from hardwarelibrary.physicaldevice import PhysicalDevice from hardwarelibrary.powermeters.powermeterdevice import PowerMeterDevice +from hardwarelibrary.powermeters.capabilities import WavelengthCalibratable FLOAT_PATTERN = r"([-+]?\d*\.?\d+(?:[eE][-+]?\d+)?)" -class FieldMasterDevice(PowerMeterDevice): +class FieldMasterDevice(PowerMeterDevice, WavelengthCalibratable): """Coherent FieldMaster GS laser power/energy meter over RS-232. The meter has no USB identity of its own: it connects through a generic diff --git a/hardwarelibrary/powermeters/integradevice.py b/hardwarelibrary/powermeters/integradevice.py index c2a08cb..34c2ed8 100644 --- a/hardwarelibrary/powermeters/integradevice.py +++ b/hardwarelibrary/powermeters/integradevice.py @@ -2,9 +2,10 @@ from enum import Enum from hardwarelibrary.communication import USBPort, TextCommand, MultilineTextCommand from hardwarelibrary.powermeters.powermeterdevice import PowerMeterDevice +from hardwarelibrary.powermeters.capabilities import WavelengthCalibratable from hardwarelibrary.notificationcenter import NotificationCenter, Notification -class IntegraDevice(PowerMeterDevice): +class IntegraDevice(PowerMeterDevice, WavelengthCalibratable): classIdProduct = 0x0300 classIdVendor = 0x1ad5 commands = { diff --git a/hardwarelibrary/powermeters/powermeterdevice.py b/hardwarelibrary/powermeters/powermeterdevice.py index 2f5924f..f24a56b 100644 --- a/hardwarelibrary/powermeters/powermeterdevice.py +++ b/hardwarelibrary/powermeters/powermeterdevice.py @@ -5,6 +5,7 @@ from hardwarelibrary.communication import USBPort, TextCommand from hardwarelibrary.physicaldevice import * from hardwarelibrary.notificationcenter import NotificationCenter, Notification +from hardwarelibrary.powermeters.capabilities import Capability class PowerMeterNotification(Enum): didMeasure = "didMeasure" @@ -14,20 +15,22 @@ class PowerMeterDevice(PhysicalDevice): def __init__(self, serialNumber:str, idProduct:int, idVendor:int): super().__init__(serialNumber, idProduct, idVendor) self.absolutePower = 0 - self.calibrationWavelength = None - # Hardware hooks a driver must implement, on top of doInitializeDevice - # and doShutdownDevice inherited from PhysicalDevice. - @abstractmethod - def doGetAbsolutePower(self): - ... + 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)] - @abstractmethod - def doGetCalibrationWavelength(self): - ... + def hasCapability(self, capabilityClass) -> bool: + return isinstance(self, capabilityClass) + # Measuring absolute power is the one capability every power meter has, so + # its hook stays on the base; optional capabilities live in mixins. @abstractmethod - def doSetCalibrationWavelength(self, wavelength): + def doGetAbsolutePower(self): ... def measureAbsolutePower(self): @@ -36,13 +39,5 @@ def measureAbsolutePower(self): NotificationCenter().postNotification(PowerMeterNotification.didMeasure, notifyingObject=self, userInfo=power) return power - def getCalibrationWavelength(self): - self.doGetCalibrationWavelength() - return self.calibrationWavelength - - def setCalibrationWavelength(self, wavelength): - self.doSetCalibrationWavelength(wavelength) - self.doGetCalibrationWavelength() - def doGetStatusUserInfo(self): return self.measureAbsolutePower() diff --git a/hardwarelibrary/tests/testFieldMasterDevice.py b/hardwarelibrary/tests/testFieldMasterDevice.py index 64d2964..4bda9ac 100644 --- a/hardwarelibrary/tests/testFieldMasterDevice.py +++ b/hardwarelibrary/tests/testFieldMasterDevice.py @@ -2,6 +2,9 @@ import unittest from hardwarelibrary.powermeters import FieldMasterDevice, DebugFieldMasterDevice +from hardwarelibrary.powermeters import ( + WavelengthCalibratable, AutoScalable, ScaleAdjustable, +) from hardwarelibrary.powermeters.powermeterdevice import PowerMeterNotification from hardwarelibrary.notificationcenter import NotificationCenter @@ -44,6 +47,28 @@ def testEnergy(self): self.assertIsInstance(self.device.getEnergy(), float) +class TestFieldMasterCapabilities(unittest.TestCase): + def setUp(self): + self.device = DebugFieldMasterDevice() + + def testDeclaresWavelengthCalibratable(self): + self.assertTrue(self.device.hasCapability(WavelengthCalibratable)) + + def testDoesNotDeclareScaleCapabilities(self): + self.assertFalse(self.device.hasCapability(AutoScalable)) + self.assertFalse(self.device.hasCapability(ScaleAdjustable)) + + def testCapabilitiesListsOnlyDeclaredMixins(self): + self.assertEqual(self.device.capabilities(), [WavelengthCalibratable]) + + def testCapabilitiesExcludeDeviceClass(self): + # The driver is itself a Capability subclass, but capabilities() must + # report only the mixins, never the device class or its base. + capabilities = self.device.capabilities() + self.assertNotIn(DebugFieldMasterDevice, capabilities) + self.assertNotIn(FieldMasterDevice, capabilities) + + class TestFieldMasterDevice(unittest.TestCase): def setUp(self): self.device = FieldMasterDevice() From 6530e81bbdc3c4123f94a29a19fcef6bafe379fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20C=C3=B4t=C3=A9?= Date: Mon, 6 Jul 2026 15:38:30 -0400 Subject: [PATCH 2/2] Make PhysicalDevice a cooperative init base so capability mixins initialize Problem: PhysicalDevice.__init__ did not call super().__init__(), and in the MRO of a device that mixes in capabilities (e.g. IntegraDevice(PowerMeterDevice, WavelengthCalibratable)) PhysicalDevice sits ahead of the mixins. The init chain therefore terminated at PhysicalDevice and never reached a mixin's __init__. This forced mixins to fake instance defaults with class attributes, and any future mixin that actually initialized per-instance state would be silently skipped. DebugFieldMasterDevice made it worse by calling PhysicalDevice.__init__ directly, also skipping PowerMeterDevice (so absolutePower was never set). Solution: End PhysicalDevice.__init__ with super().__init__(). It still consumes the device-identity arguments and forwards nothing, so a mixin __init__ must take no required arguments and call super().__init__() itself; this contract is documented on the Capability base. WavelengthCalibratable and ScaleAdjustable now set their per-instance state (calibrationWavelength, scale) in a cooperative __init__ instead of shared class attributes. DebugFieldMasterDevice goes through super().__init__() so it runs the full chain. Verified across families (Matisse, Labjack, power meters); full suite passes (398 passed, 235 skipped). Co-Authored-By: Claude Opus 4.8 (1M context) --- hardwarelibrary/physicaldevice.py | 7 +++++++ hardwarelibrary/powermeters/capabilities.py | 15 +++++++++++++-- hardwarelibrary/powermeters/fieldmasterdevice.py | 9 +++++---- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/hardwarelibrary/physicaldevice.py b/hardwarelibrary/physicaldevice.py index def6906..86c1899 100644 --- a/hardwarelibrary/physicaldevice.py +++ b/hardwarelibrary/physicaldevice.py @@ -71,6 +71,13 @@ def __init__(self, serialNumber:str, idProduct:int, idVendor:int): self.monitoring = None self.refreshInterval = 1.0 + # Cooperative base: forward to the rest of the MRO so capability mixins + # mixed in alongside a device (e.g. WavelengthCalibratable) get their + # own __init__ run. The device-identity arguments are consumed here, so + # nothing is forwarded; a mixin __init__ must therefore take no required + # arguments and call super().__init__() itself. + super().__init__() + @classmethod def vidpids(cls): if cls.usesGenericSerialConverter: diff --git a/hardwarelibrary/powermeters/capabilities.py b/hardwarelibrary/powermeters/capabilities.py index 9e52e3a..3a9d339 100644 --- a/hardwarelibrary/powermeters/capabilities.py +++ b/hardwarelibrary/powermeters/capabilities.py @@ -2,6 +2,11 @@ class Capability(ABC): + # Capability mixins are combined with a PhysicalDevice subclass, which sits + # ahead of them in the MRO. PhysicalDevice is a cooperative base (it calls + # super().__init__() after consuming the device-identity arguments), so a + # mixin that holds per-instance state may define __init__ as long as it + # takes no required arguments and forwards with super().__init__(). pass @@ -9,7 +14,10 @@ class WavelengthCalibratable(Capability): unit = "nm" isReadable = True isWritable = True - calibrationWavelength = None + + def __init__(self): + super().__init__() + self.calibrationWavelength = None def getCalibrationWavelength(self): self.doGetCalibrationWavelength() @@ -60,7 +68,10 @@ class ScaleAdjustable(Capability): unit = "W" isReadable = True isWritable = True - scale = None + + def __init__(self): + super().__init__() + self.scale = None def getScale(self): self.doGetScale() diff --git a/hardwarelibrary/powermeters/fieldmasterdevice.py b/hardwarelibrary/powermeters/fieldmasterdevice.py index 535935f..ced8204 100644 --- a/hardwarelibrary/powermeters/fieldmasterdevice.py +++ b/hardwarelibrary/powermeters/fieldmasterdevice.py @@ -157,10 +157,11 @@ class DebugFieldMasterDevice(FieldMasterDevice): usesGenericSerialConverter = False # debug device has its own fake identity def __init__(self, serialNumber='debug'): - PhysicalDevice.__init__(self, serialNumber=serialNumber, - idProduct=self.classIdProduct, idVendor=self.classIdVendor) - self.portPath = None - self.terminator = b'\n' + # Go through the full cooperative chain (FieldMaster -> PowerMeter -> + # PhysicalDevice -> mixins) with the debug identity, rather than jumping + # straight to PhysicalDevice and skipping the intermediate __init__s. + super().__init__(serialNumber=serialNumber, + idProduct=self.classIdProduct, idVendor=self.classIdVendor) self.version = "Debug FieldMaster GS 1.0" self._simulatedPower = 1.43e-3 self._calibrationWavelengthInNanometers = 1064.0