BioTek Synergy HTX Backend

Greetings!:vulcan_salute:

Happy new year! Hopefully 2026 will be better than 2025 in every respect.

Thank you for developing PLR, it is a great initiative! I was very happy to discover that it already implements support for some Agilent (BioTek) plate readers, including the Synergy H1. I would like to ask whether there is a plan to support the Synergy HTX multimode reader, and if not, whether I could lend a hand to implement the backend for it. I am not sure how different it is from the Synergy H1.

Cheers, and thank you again for the effort that you are putting into this library.

4 Likes

Hello!

My name is Nat, and I was the one who developed the Synergy H1 backend :slightly_smiling_face: .

I haven’t personally heard if anyone is working on the Synergy HTX, but if not, the first test I would do is to see if the current BioTekPlateReaderBackend or SynergyH1Backend will work with the HTX, such as testing out the door and reading functions. After all, that was how I was able to develop the SynergyH1Backend by testing it with the CytationBackend, since both plate readers are controlled by the Gen5 software, which is what the PLR code is based on.

And who knows, hopefully, the HTX shares the same base codes as well, except for some minor modifications that may be needed to make it fully work. :crossed_fingers:

3 Likes

note that the backends currently in PLR, the one @Nat wrote, works for v1.01 but not for firmware version 2.24 (that we currently know about). this is because no one with a device running 2.24 has contributed it yet. and unfortunately the api is not the same

2 Likes

Hi Nat,

Apologies, I should have introduced myself - I’m Alex, and I’m currently working on an automation project with a lab that has the Synergy HTX. Thank you for implementing the Synergy H1 backend and for the insight. It did occur to me that the base BioTekPlateReaderBackend might already be able to interface with the HTXsince, as far as I can tell, it implements most of the functionality of the `H1`. I will try it at the lab, and I will let you know how it goes!

1 Like

I see, that’s good thing to know - I hope that our firmware is v1.01:crossed_fingers:, but if it’s not, I would be keen to work on it. When you say that nobody has contributed it, do you mean the firmware itself, a pcap file or something else? How do you normally approach this? Do we need to get Agilent involved?

At any rate, if the current backend (mostly) works, this would already be a huge step forward for us, so thank you both @rickwierenga @Nat.

I’ve done a few tests with the Synergy H1 backend on our Synergy HT - we can connect to the instrument, get the serial number, open the door, and close the door. None of the read commands are working for the Synergy HT though :face_with_peeking_eye: so some work to be done there

3 Likes

Hi @jordan, that is good to hear, as you already got your foot in the door!

Is there a specific error you are running into when you try to send the read commands? Is the plate reading anything at all, or is the script terminating too soon before you get your data?

I only ask this because the SynergyH1Backend references 99% of the code from the BioTekPlateReaderBackend. The only modification is the _read_until function utilized in each read function (absorbance, luminescence, and fluorescence). Therefore, I would suggest testing the BioTekPlateReaderBackend to see if it could fix the problem, if you haven’t already.

After all, the same thing happened to me when I was first implementing the SynergyH1Backend. Back then, there was only the Cytation5Backend, and I tested that with our Syngery H1. Everything worked, except for the read commands. Therefore, with @rickwierenga’s help, the Cytation5Backend was separated into the BioTekPlateReaderBackend as the base class, and the SynergyH1Backend and CytationBackend as the subclasses.

2 Likes

This is kind of embarrassing but I discovered today we actually have a Synergy plate reader at Retro :see_no_evil_monkey: It is even at version 2.15 so probably similar to yours in terms of firmware

This code actually works for me …

import asyncio                                                                               
from pylabrobot.plate_reading.agilent import SynergyHTBackend                                
from pylabrobot.resources import Cor_96_wellplate_360ul_Fb                                   
                                                                                              
# Setup                                                                                  
backend = SynergyHTBackend()                                                             
await backend.setup()                                                                    
                                                                                        
print(f"Firmware: {backend.version}")
print(f"Temperature: {await backend.get_current_temperature():.1f}°C")

# Create plate and get wells
plate = Cor_96_wellplate_360ul_Fb("test_plate")                                          
wells = [plate.get_item(i) for i in range(plate.num_items)]                              
                                                                                        
# Read OD600                                                                             
result = await backend.read_absorbance(plate, wells, wavelength=600)                     
                                                                                        
print(result)

I had to replace open and close with the following:

  async def open(self, slow: bool = False) -> None:
    """Open the plate reader door / eject plate.

    Note: slow parameter is ignored on Synergy HT (not supported).
    """
    # Synergy HT doesn't support slow mode command (&), so skip it
    return await self.send_command("J")


  async def close(self, plate: Optional[Plate] = None, slow: bool = False) -> None:
    """Close the plate reader door / load plate.

    Note: slow parameter is ignored on Synergy HT (not supported).
    """
    # Synergy HT doesn't support slow mode command (&), so skip it
    self._plate = None
    if plate is not None:
      await self.set_plate(plate)
    return await self.send_command("A")

kind of hacky right now, just sharing so people can test before I make a PR for it

5 Likes

I discovered today we actually have a Synergy plate reader

Oh wow, that must have been a nice surprise! :slight_smile:

I haven’t had a chance to test it yet, but I hope to have some feedback for you soon!

2 Likes

Howdyyyyy

I’m trying to make this work for our Synergy HT and it doesn’t quite do the trick for me

When I set up a Synergy HT backend with the changes for open and close, the open and close do work for me

However when I run (mostly) the same code as above:

import asyncio                                                                               
from pylabrobot.plate_reading.agilent import SynergyHTBackend                                
from pylabrobot.resources import Cor_96_wellplate_360ul_Fb     

# Setup                                                                                  
backend = SynergyHTBackend()                                                             
await backend.setup()                                                                    
                                                                                        
print(f"Firmware: {backend.version}")
print(f"Temperature: {await backend.get_current_temperature():.1f}°C")

# Create plate and get wells
plate = Cor_96_wellplate_360ul_Fb("test_plate")                                          
wells = [plate.get_item(i) for i in range(plate.num_items)]                              
                                                                                        
# Read OD600                                                                             
result = await backend.read_absorbance(plate, wells, wavelength=600)                     
                                                                                        
print(result)

It times out trying to run the read command:

2026-02-17 21:38:01,066 - pylabrobot.plate_reading.agilent.biotek_backend - INFO - SynergyHTBackend setting up
2026-02-17 21:38:01,164 - pylabrobot.io.ftdi - INFO - Successfully opened FTDI device: FTNE6HGA
Firmware: 2.24
Temperature: 22.4°C
---------------------------------------------------------------------------
TimeoutError                              Traceback (most recent call last)
Cell In[1], line 20
     17 await backend.close()                                                                                            
     18 await backend.open()   
---> 20 result = await backend.read_absorbance(plate, wells, wavelength=600)    
     22 print(result)

File ~/oppy_scripts/pylabrobot/pylabrobot/plate_reading/agilent/biotek_backend.py:371, in BioTekPlateReaderBackend.read_absorbance(self, plate, wells, wavelength)
    368 cmd = cmd + checksum + "\x03"
    369 await self.send_command("D", cmd)
--> 371 resp = await self.send_command("O")
    372 assert resp == b"\x060000\x03"
    374 # read data

File ~/oppy_scripts/pylabrobot/pylabrobot/plate_reading/agilent/biotek_backend.py:190, in BioTekPlateReaderBackend.send_command(self, command, parameter, wait_for_response, timeout)
    188 response: Optional[bytes] = None
    189 if wait_for_response or parameter is not None:
--> 190   response = await self._read_until(
    191     b"\x06" if parameter is not None else b"\x03", timeout=timeout
    192   )
    194 if parameter is not None:
    195   await self.io.write(parameter.encode())

File ~/oppy_scripts/pylabrobot/pylabrobot/plate_reading/agilent/biotek_synergyht_backend.py:65, in SynergyHTBackend._read_until(self, terminator, timeout, chunk_size)
     61 if time.time() > deadline:
     62   logger.debug(
     63     f"{self.__class__.__name__} _read_until timed out; partial buffer (hex): %s", buf.hex()
     64   )
---> 65   raise TimeoutError(
     66     f"{self.__class__.__name__} _read_until timed out waiting for {terminator!r}; partial={buf.hex()}"
     67   )
     69 try:
     70   data = await self.io.read(chunk_size)

TimeoutError: SynergyHTBackend _read_until timed out waiting for b'\x03'; partial=15

I can see the firmware versions are different so maybe that’s involved somehow but I mostly feel a bit stuck on how to approach fixing this :grimacing: so any suggestions would be MUCH appreciated

My SynergyHT Backend for reference:

import asyncio
import logging
import time
from typing import Optional

from pylibftdi import FtdiError

from pylabrobot.plate_reading.agilent.biotek_backend import BioTekPlateReaderBackend
from pylabrobot.resources import Plate

logger = logging.getLogger(__name__)


class SynergyHTBackend(BioTekPlateReaderBackend):
  """Backend for Agilent BioTek Synergy HT plate readers."""

  @property
  def supports_heating(self):
    return True

  @property
  def supports_cooling(self):
    return False

  @property
  def focal_height_range(self):
    return (4.5, 10.68)
  
  async def open(self, slow: bool = False) -> None:
    """Open the plate reader door / eject plate.

    Note: slow parameter is ignored on Synergy HT (not supported).
    """
    # Synergy HT doesn't support slow mode command (&), so skip it
    return await self.send_command("J")

  async def close(self, plate: Optional[Plate] = None, slow: bool = False) -> None:
    """Close the plate reader door / load plate.

    Note: slow parameter is ignored on Synergy HT (not supported).
    """
    # Synergy HT doesn't support slow mode command (&), so skip it
    self._plate = None
    if plate is not None:
      await self.set_plate(plate)
    return await self.send_command("A")

  async def _read_until(
    self, terminator: bytes, timeout: Optional[float] = None, chunk_size: int = 512
  ) -> bytes:
    if timeout is None:
      timeout = self.timeout

    deadline = time.time() + timeout
    buf = bytearray()

    retries = 0
    max_retries = 3

    while True:
      if time.time() > deadline:
        logger.debug(
          f"{self.__class__.__name__} _read_until timed out; partial buffer (hex): %s", buf.hex()
        )
        raise TimeoutError(
          f"{self.__class__.__name__} _read_until timed out waiting for {terminator!r}; partial={buf.hex()}"
        )

      try:
        data = await self.io.read(chunk_size)
        if len(data) == 0:
          await asyncio.sleep(0.02)
          continue

        buf.extend(data)

        if terminator in buf:
          idx = buf.index(terminator) + len(terminator)
          full = bytes(buf[:idx])
          logger.debug(
            f"{self.__class__.__name__} _read_until received %d bytes (hex prefix): %s",
            len(full),
            full[:200].hex(),
          )
          return full

      except FtdiError as e:
        retries += 1
        logger.warning(
          f"{self.__class__.__name__} transient FtdiError while reading: %s — retrying", e
        )

        if retries >= max_retries:
          logger.warning(
            f"{self.__class__.__name__} too many FtdiError retries ({max_retries}) — stopping", e
          )
          raise

        await asyncio.sleep(0.05)
        continue
      except Exception:
        raise

So I finally had a chance to play with the device and PLR. @rickwierenga I used your code, but the backend could not find the device, even though it seems to come up in the list of USB devices when I enumerate all of them using pylabrobot.io.ftdi.usb core - the USB device ID matches (as confirmed by Zadig, see the screenshots), but the backend doesn’t connect to it:

# synergy.py
from typing import Optional
import asyncio
from pylabrobot.resources import Plate
from pylabrobot.plate_reading.agilent import SynergyH1Backend

class SynergyHTBackend(SynergyH1Backend):
    async def open(self, slow: bool = False) -> None:
        """Open the plate reader door / eject plate.

        Note: slow parameter is ignored on Synergy HT (not supported).
        """
        # Synergy HT doesn't support slow mode command (&), so skip it
        return await self.send_command("J")


    async def close(self, plate: Optional[Plate] = None, slow: bool = False) -> None:
        """Close the plate reader door / load plate.

        Note: slow parameter is ignored on Synergy HT (not supported).
        """
        # Synergy HT doesn't support slow mode command (&), so skip it
        self._plate = None
        if plate is not None:
            await self.set_plate(plate)
        return await self.send_command("A")
# main.py
import asyncio
from synergy import SynergyHTBackend
from pylabrobot.io.ftdi import usb

async def main():

    for dev in usb.core.find(find_all=True):
        dev_id = f"{dev.idVendor:04x}:{dev.idProduct:04x}"
        print(f"Found device {dev_id}...")

    try:
        backend = SynergyHTBackend()
        await backend.setup()
    except Exception as e:
        print(f"{type(e)}: {e}")

if __name__ == "__main__":
    asyncio.run(main())

We made sure that the Gen5 software was closed, and we even turned the platereader off and on again to make sure that the port was free (yay Windows :roll_eyes:). Do I need to pass the device ID explicitly? I can see in your examples above that it works without the device_id argument, so it’s quite possible that I’m doing something wrong (or not doing something that I should be doing). But on the plus side, nothing crashed, so that’s encouraging!:smiling_face:

Edit: It just occurred to me that I wasn’t looking at the actual error…it seems that the backend actually connects but fails to run setup() (cf. updated screenshot). I’m guessing that this is because I’m running it under Windows?

1 Like

Progress!

Based on the error I got last time, I installed Ubuntu 24.04 via WSL, then I installed PLR and all dependencies as necessary to access the USB, etc. It didn’t work out of the box, so I did some digging and found that an extra step is needed in order to be able to access USB devices from WSL. You have to install a package called usbpid-win as described here. Once it’s installed, you have to bind the device. Note that you have to do this in PS with administrator privileges, but thankfully it’s persistent, so you only have to do it once ever. This basically shares the device with WSL:

usbipd bind --busid=<BUSID>

Then you have to attach it (that’s non-persistent). You can do it in the Linux terminal or in PS:

usbipd attach -a -w -u -b=<BUSID>

After this, your device should be visible to Linux (check with lsusb).

Now, at that stage I ran the same minimal program above, and it found the device (yay!), but it gave me a different error (actually, that’s also kind of a yay since at least it tried to initialise it rather than throwing its arms up in the air like last time):

image

I’m debugging this remotely, which is a pain, so I can post a more detailed error next time when I’m actually in the lab…but if you already have an idea of what this might mean, I’d appreciate a pointer.

Enjoy your weekend!

1 Like