Apologies, I misread your example. I see it now: InfiniteAbsorbance is the concrete backend with the firmware commands, TecanInfiniteDriver is the shared IO connection underneath. Same universal ABCs, just the concrete layer split per capability for larger backends. That makes sense for big drivers.
To make sure I follow the layers correctly, hereâs how it looks today on the capability branch (Cytation 1):
class Cytation1(Resource, Device):
def __init__(self, ...):
backend = BioTekBackend(device_id=device_id)
Device.__init__(self, backend=backend)
self.absorbance = AbsorbanceCapability(backend=backend)
self.luminescence = LuminescenceCapability(backend=backend)
self.fluorescence = FluorescenceCapability(backend=backend)
One backend, all capabilities share it. And your new example for larger backends:
class Infinite:
def __init__(self, ...):
self.driver = InfiniteDriver(...)
self.absorbance_reader = AbsorbanceReader(
backend=InfiniteAbsorbance(driver=self.driver))
self.temperature_controller = TemperatureController(
backend=InfiniteTemperatureController(driver=self.driver))
self.shaker = Shaker(
backend=InfiniteShaker(driver=self.driver))
Same architecture, concrete backends split per capability. Both work.
One thing I notice: the naming is different between the two examples, thatâs likely where my confusion also came from. On the branch itâs AbsorbanceCapability, in the new example itâs AbsorbanceReader.
Would more consistent naming help readability? Something like:
# Simple (small backend, Cytation pattern)
class Cytation1(Resource, Device):
def __init__(self, ...):
backend = CytationDriver(device_id=device_id)
Device.__init__(self, backend=backend)
self.absorbance = AbsorbanceCapability(backend=backend)
self.luminescence = LuminescenceCapability(backend=backend)
self.fluorescence = FluorescenceCapability(backend=backend)
self._capabilities = [self.absorbance, self.luminescence, self.fluorescence]
# Split (large backend, Infinite pattern)
class Infinite(Resource, Device):
def __init__(self, ...):
driver = InfiniteDriver(...)
Device.__init__(self, backend=driver)
self.absorbance = AbsorbanceCapability(
backend=InfiniteAbsorbanceDriver(driver=driver))
self.temperature = TemperatureControlCapability(
backend=InfiniteTemperatureDriver(driver=driver))
self.shaker = ShakingCapability(
backend=InfiniteShakerDriver(driver=driver))
self._capabilities = [self.absorbance, self.temperature, self.shaker]
But since you seem to not have decided on the naming, let me propose another option:
class Infinite(Resource, Device):
def __init__(self, ...):
connection = InfiniteConnection(...) # shared IO (serial/USB)
Device.__init__(self, backend=connection)
self.absorbance = Absorbance( # frontend
backend=InfiniteAbsorbanceDriver( # concrete driver
connection=connection))
self.temperature = Temperature( # frontend
backend=InfiniteTemperatureDriver( # concrete driver
connection=connection))
And on the ABC naming: CanReadAbsorbance instead of AbsorbanceBackend reads better in the two places it actually shows up:
# ABC â standalone, no DeviceBackend inheritance
class CanReadAbsorbance(ABC):
@abstractmethod
async def read_absorbance(self, wavelength: int, wells: list): ...
# Class definition â reads like a spec sheet
class InfiniteAbsorbanceDriver(DeviceBackend, CanReadAbsorbance):
...
# Type hint â says what the frontend needs
class Absorbance:
def __init__(self, backend: CanReadAbsorbance):
...
backend: CanReadAbsorbance says âgive me anything that can read absorbance.â backend: AbsorbanceBackend says âgive me an absorbance backendâ, which is circular, the name just restates the type.
Note: CanReadAbsorbance inherits from ABC only, not from DeviceBackend. The capability interface stays separate from lifecycle management (setup/stop). They meet on the concrete driver: InfiniteAbsorbanceDriver(DeviceBackend, CanReadAbsorbance). This avoids the diamond where every backend ABC carries DeviceBackend in its chain.
I donât know exactly if this would fit the LIFO build up or teardown, and lifecycle stuff.