Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
b132be7
tecan spark backend
xbtu2 Dec 20, 2025
fbec36f
use enum for endpoints
xbtu2 Dec 20, 2025
16edc58
add time
xbtu2 Dec 21, 2025
ad9843d
format
xbtu2 Dec 21, 2025
a799855
fix typo
xbtu2 Dec 21, 2025
4190426
Update pylabrobot/plate_reading/tecan/spark20m/spark_reader_async.py
xbtu2 Dec 21, 2025
c937c24
remove unused method
xbtu2 Dec 21, 2025
7560ed3
use plr usb io
xbtu2 Jan 13, 2026
1e2c30a
Merge branch 'main' into tecan_spark
xbtu2 Jan 13, 2026
0c99649
fix test
xbtu2 Jan 13, 2026
eaf2f3b
Merge branch 'main' into tecan_spark
xbtu2 Jan 14, 2026
24ec263
Use io.binary.Reader in spark_packet_parser
rickwierenga Jan 31, 2026
5dd91c8
Simplify Spark control architecture
rickwierenga Jan 31, 2026
b56ffdd
Merge remote-tracking branch 'origin/main' into tecan_spark
rickwierenga Jan 31, 2026
aaa6fae
Rename control attributes to include _control suffix
rickwierenga Jan 31, 2026
9747549
Refactor spark_packet_parser.py to use binary.py Reader
rickwierenga Jan 22, 2026
3b5f4b7
Fix floating point comparison in spark_processor_tests
rickwierenga Jan 31, 2026
2e1db3c
Make USB max_workers configurable per-instance
rickwierenga Jan 31, 2026
12af73b
Fix timeout conversion for zero-length packet write
rickwierenga Jan 31, 2026
31949fa
Merge remote-tracking branch 'origin/main' into tecan_spark
rickwierenga Jan 31, 2026
847d895
Use item_dx and item_dy properties in SparkBackend
rickwierenga Jan 31, 2026
e95d88d
fix send_commend and tests
xbtu2 Jan 31, 2026
a0da054
fix send_command error handling
xbtu2 Feb 1, 2026
d9e4c4e
Move Spark enums to dedicated enums.py module
rickwierenga Jan 31, 2026
d6e76aa
Add type annotations to spark20m module for mypy --strict
rickwierenga Feb 2, 2026
95a9bfa
Refactor spark processors from classes to functions
rickwierenga Feb 4, 2026
968531c
Use nan instead of 'Error' string in spark processors
rickwierenga Feb 4, 2026
f45693d
Rename camelCase variables/parameters to snake_case
rickwierenga Feb 4, 2026
7cefe4e
Use statistics.mean() and remove unnecessary * 1.0
rickwierenga Feb 4, 2026
e5de32f
Use reversed(range()) instead of range(n-1, -1, -1)
rickwierenga Feb 4, 2026
b559985
Make spark code more Pythonic
rickwierenga Feb 4, 2026
a0d970f
Remove 30 duplicate abbreviated methods from CameraControl
rickwierenga Feb 4, 2026
8e55bab
Make _format_scan_range a staticmethod
rickwierenga Feb 4, 2026
217887d
Remove misleading '# Unused' comment on focal_height parameter
rickwierenga Feb 4, 2026
461c0ed
Replace magic numbers with named constants in spark_processor
rickwierenga Feb 4, 2026
8181847
Add division by zero protection in fluorescence K calculation
rickwierenga Feb 5, 2026
e4d0322
Fix potential task leak in send_command
rickwierenga Feb 5, 2026
b6a779d
Remove duplicate enums from sensor_control, import from spark_enums
rickwierenga Feb 5, 2026
39a7e8b
Add device context to background read error messages
rickwierenga Feb 5, 2026
19c6463
Add warnings when absorbance ratios are invalid
rickwierenga Feb 5, 2026
cc2fab3
Include hex dump in packet parse error messages
rickwierenga Feb 5, 2026
97750cb
fix command strings and enums
xbtu2 Feb 5, 2026
09d4549
Merge branch 'main' into tecan_spark
xbtu2 Feb 5, 2026
4997d30
Properly await cancelled response_task in send_command
rickwierenga Feb 5, 2026
50aa783
Replace attempt-based retry with wall-clock timeout in _get_response
rickwierenga Feb 5, 2026
ab79ec1
Protect fluorescence RFU calculation from division by zero
rickwierenga Feb 5, 2026
707f5b1
Remove duplicate enum definitions from optics_control.py
rickwierenga Feb 5, 2026
f78e07d
Remove duplicate ScanDirection and ScanDarkState from measurement_con…
rickwierenga Feb 5, 2026
6d74c4d
Fix type annotation issues for mypy --strict
rickwierenga Feb 5, 2026
6179ef9
refactor io/usb.py
xbtu2 Feb 6, 2026
8c147f4
fix duplicate commands
xbtu2 Feb 6, 2026
77927c9
fix import order
xbtu2 Feb 6, 2026
45f3158
add endpoint override in io/usb.py; refactor read/write methods in sp…
xbtu2 Feb 7, 2026
3a3cc1c
fix import
xbtu2 Feb 7, 2026
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
122 changes: 97 additions & 25 deletions pylabrobot/io/usb.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import time
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from typing import TYPE_CHECKING, List, Optional
from typing import TYPE_CHECKING, Callable, List, Optional

from pylabrobot.io.capture import Command, capturer, get_capture_or_validation_active
from pylabrobot.io.errors import ValidationError
Expand Down Expand Up @@ -51,6 +51,10 @@ def __init__(
packet_read_timeout: int = 3,
read_timeout: int = 30,
write_timeout: int = 30,
configuration_callback: Optional[Callable[["usb.core.Device"], None]] = None,
max_workers: int = 1,
read_endpoint_address: Optional[int] = None,
write_endpoint_address: Optional[int] = None,
):
"""Initialize an io.USB object.

Expand All @@ -63,6 +67,11 @@ def __init__(
packet_read_timeout: The timeout for reading packets from the machine in seconds.
read_timeout: The timeout for reading from the machine in seconds.
write_timeout: The timeout for writing to the machine in seconds.
read_endpoint_address: The address of the read endpoint. If `None`, find the first IN endpoint.
write_endpoint_address: The address of the write endpoint. If `None`, find the first OUT endpoint.
configuration_callback: A callback that takes the device object as an argument and performs
any necessary configuration. If `None`, `dev.set_configuration()` is called.
max_workers: The maximum number of worker threads for USB I/O operations.
"""

super().__init__()
Expand All @@ -82,8 +91,12 @@ def __init__(
self.packet_read_timeout = packet_read_timeout
self.read_timeout = read_timeout
self.write_timeout = write_timeout
self.read_endpoint_address = read_endpoint_address
self.write_endpoint_address = write_endpoint_address
self.configuration_callback = configuration_callback
self.max_workers = max_workers

self.dev: Optional["usb.core.Device"] = None # TODO: make this a property
self.dev: Optional[usb.core.Device] = None # TODO: make this a property
self.read_endpoint: Optional[usb.core.Endpoint] = None
self.write_endpoint: Optional[usb.core.Endpoint] = None

Expand Down Expand Up @@ -114,13 +127,15 @@ async def write(self, data: bytes, timeout: Optional[float] = None):
raise RuntimeError("Call setup() first.")
await loop.run_in_executor(
self._executor,
lambda: dev.write(write_endpoint, data, timeout=timeout),
lambda: dev.write(
write_endpoint, data, timeout=int(timeout * 1000)
), # PyUSB expects timeout in milliseconds
)
if len(data) % write_endpoint.wMaxPacketSize == 0:
# send a zero-length packet to indicate the end of the transfer
await loop.run_in_executor(
self._executor,
lambda: dev.write(write_endpoint, b"", timeout=timeout),
lambda: dev.write(write_endpoint, b"", timeout=int(timeout * 1000)),
)
logger.log(LOG_LEVEL_IO, "%s write: %s", self._unique_id, data)
capturer.record(
Expand All @@ -131,25 +146,56 @@ async def write(self, data: bytes, timeout: Optional[float] = None):
)
)

def _read_packet(self, size: Optional[int] = None) -> Optional[bytearray]:
def _read_packet(
self,
size: Optional[int] = None,
timeout: Optional[float] = None,
endpoint: Optional[int] = None,
) -> Optional[bytearray]:
"""Read a packet from the machine.

Args:
size: The maximum number of bytes to read. If `None`, read up to wMaxPacketSize bytes.
timeout: The timeout for reading from the device in seconds. If `None`, use the default
timeout (specified by the `packet_read_timeout` attribute).
endpoint: The endpoint address to read from. If `None`, use the default read endpoint.

Returns:
A bytearray containing the data read, or None if no data was received.
"""

assert self.dev is not None and self.read_endpoint is not None, "Device not connected."
assert self.dev is not None, "Device not connected."

read_size = size if size is not None else self.read_endpoint.wMaxPacketSize
ep = endpoint if endpoint is not None else self.read_endpoint
if ep is None:
raise RuntimeError("Read endpoint not found. Call setup() first.")

# Get max packet size if size is not provided
if size is None:
if isinstance(ep, int):
# Find endpoint object to get max packet size
cfg = self.dev.get_active_configuration()
intf = cfg[(0, 0)]
ep_obj = usb.util.find_descriptor(
intf,
custom_match=lambda e: e.bEndpointAddress == ep,
)
if ep_obj is None:
raise ValueError(f"Endpoint 0x{ep:02x} not found.")
read_size = ep_obj.wMaxPacketSize
else:
read_size = ep.wMaxPacketSize
else:
read_size = size

if timeout is None:
timeout = self.packet_read_timeout

try:
res = self.dev.read(
self.read_endpoint,
ep,
read_size,
timeout=int(self.packet_read_timeout * 1000), # timeout in ms
timeout=int(timeout * 1000), # timeout in ms
)

if res is not None:
Expand Down Expand Up @@ -318,7 +364,7 @@ def ctrl_transfer(

return bytearray(res)

async def setup(self):
async def setup(self, empty_buffer=True):
"""Initialize the USB connection to the machine."""

if self.dev is not None:
Expand Down Expand Up @@ -347,22 +393,37 @@ async def setup(self):

# set the active configuration. With no arguments, the first
# configuration will be the active one
self.dev.set_configuration()
if self.configuration_callback is not None:
self.configuration_callback(self.dev)
else:
self.dev.set_configuration()

cfg = self.dev.get_active_configuration()
intf = cfg[(0, 0)]

self.write_endpoint = usb.util.find_descriptor(
intf,
custom_match=lambda e: usb.util.endpoint_direction(e.bEndpointAddress)
== usb.util.ENDPOINT_OUT,
)
if self.write_endpoint_address is not None:
self.write_endpoint = usb.util.find_descriptor(
intf,
custom_match=lambda e: e.bEndpointAddress == self.write_endpoint_address,
)
else:
self.write_endpoint = usb.util.find_descriptor(
intf,
custom_match=lambda e: usb.util.endpoint_direction(e.bEndpointAddress)
== usb.util.ENDPOINT_OUT,
)

self.read_endpoint = usb.util.find_descriptor(
intf,
custom_match=lambda e: usb.util.endpoint_direction(e.bEndpointAddress)
== usb.util.ENDPOINT_IN,
)
if self.read_endpoint_address is not None:
self.read_endpoint = usb.util.find_descriptor(
intf,
custom_match=lambda e: e.bEndpointAddress == self.read_endpoint_address,
)
else:
self.read_endpoint = usb.util.find_descriptor(
intf,
custom_match=lambda e: usb.util.endpoint_direction(e.bEndpointAddress)
== usb.util.ENDPOINT_IN,
)

logger.info(
"Found endpoints. \nWrite:\n %s \nRead:\n %s",
Expand All @@ -371,10 +432,11 @@ async def setup(self):
)

# Empty the read buffer.
while self._read_packet() is not None:
pass
if empty_buffer:
while self._read_packet() is not None:
pass

self._executor = ThreadPoolExecutor(max_workers=1)
self._executor = ThreadPoolExecutor(max_workers=self.max_workers)

async def stop(self):
"""Close the USB connection to the machine."""
Expand All @@ -401,6 +463,8 @@ def serialize(self) -> dict:
"packet_read_timeout": self.packet_read_timeout,
"read_timeout": self.read_timeout,
"write_timeout": self.write_timeout,
"read_endpoint_address": self.read_endpoint_address,
"write_endpoint_address": self.write_endpoint_address,
}


Expand All @@ -415,6 +479,10 @@ def __init__(
packet_read_timeout: int = 3,
read_timeout: int = 30,
write_timeout: int = 30,
read_endpoint_address: Optional[int] = None,
write_endpoint_address: Optional[int] = None,
configuration_callback: Optional[Callable[["usb.core.Device"], None]] = None,
max_workers: int = 1,
):
super().__init__(
id_vendor=id_vendor,
Expand All @@ -424,10 +492,14 @@ def __init__(
packet_read_timeout=packet_read_timeout,
read_timeout=read_timeout,
write_timeout=write_timeout,
read_endpoint_address=read_endpoint_address,
write_endpoint_address=write_endpoint_address,
configuration_callback=configuration_callback,
max_workers=max_workers,
)
self.cr = cr

async def setup(self):
async def setup(self, empty_buffer=True):
pass

async def write(self, data: bytes, timeout: Optional[float] = None):
Expand Down
1 change: 1 addition & 0 deletions pylabrobot/plate_reading/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,4 @@
ImagingResult,
Objective,
)
from .tecan.spark20m.spark_backend import SparkBackend
Empty file.
11 changes: 11 additions & 0 deletions pylabrobot/plate_reading/tecan/spark20m/controls/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from .base_control import BaseControl
from .camera_control import CameraControl
from .config_control import ConfigControl
from .data_control import DataControl
from .injector_control import InjectorControl
from .measurement_control import MeasurementControl
from .movement_control import MovementControl
from .optics_control import OpticsControl
from .plate_transport_control import PlateControl
from .sensor_control import SensorControl
from .system_control import SystemControl
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from typing import Awaitable, Callable, Optional

SendCommandFunc = Callable[..., Awaitable[Optional[str]]]


class BaseControl:
def __init__(self, send_command: SendCommandFunc) -> None:
self.send_command = send_command
Loading
Loading