Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
278586b
ODTC Sila, Codec, Connection Init
cmoscy Jan 26, 2026
1358d29
Consolidate classes, async commands and rquest_id handling
cmoscy Jan 26, 2026
92143c6
Add async command pattern: CommandExecution base class and MethodExec…
cmoscy Jan 26, 2026
813e569
Doc and test notebook
cmoscy Jan 26, 2026
d41c26a
States
cmoscy Jan 26, 2026
e8d3d3a
refactor(odtc): centralize response parsing and verify idle state aft…
cmoscy Jan 26, 2026
1f9417f
Formatting and notebook example
cmoscy Jan 27, 2026
ad1a6f2
feat: Add protocol management and temperature control to Inheco ODTC
cmoscy Jan 27, 2026
94b4315
Formatting and fluid quantity checking
cmoscy Jan 27, 2026
ade262d
Sila2 Style Command Lifecycle Management
cmoscy Feb 10, 2026
cb86ea3
ODTC: send_command vs start_command, get_method/list_methods, doc con…
cmoscy Feb 11, 2026
3b46ece
Align ODTC with the standard Thermocycler resource pattern (single entry
cmoscy Feb 12, 2026
2bb0fea
Merge branch 'PyLabRobot:main' into odtc
cmoscy Feb 12, 2026
44ab52a
Reconnect without Reset. Simulation Mode. Clean up Prints for tempera…
cmoscy Feb 13, 2026
b4ad920
Merge branch 'PyLabRobot:main' into odtc
cmoscy Feb 13, 2026
b94e165
Remove redundant notebook
cmoscy Feb 13, 2026
ea6ceae
Separate PreMethods vs Methods for Viewing. Display full protocol for…
cmoscy Feb 13, 2026
8371259
Checkpoint. Data Event handling and progress.
cmoscy Feb 13, 2026
ca8d579
Refactor. Comms reorganization and ODTC Method, Protocol, Config Cons…
cmoscy Feb 13, 2026
939e436
ODTCProtocol
cmoscy Feb 14, 2026
8d7ed52
Replaced StoredProtocol with ODTCProtocol
cmoscy Feb 14, 2026
4c5a61b
Progress Polling
cmoscy Feb 14, 2026
dc91515
Register execution ids for tracking
cmoscy Feb 14, 2026
ed389ec
Fix
cmoscy Feb 14, 2026
b35db2a
wait is __await__
cmoscy Feb 14, 2026
30a3d64
Remove prints
cmoscy Feb 14, 2026
3dda9fe
Centralize ODTCProtocol Lifecycle and Progress Reporting to ODTCProgr…
cmoscy Feb 15, 2026
fd07d35
Merge branch 'PyLabRobot:main' into odtc
cmoscy Feb 15, 2026
a824c92
Formatting
cmoscy Feb 15, 2026
ab4264a
Line Formatting
cmoscy Feb 15, 2026
d49f7c6
ODTC Dimensions and serialization fix
cmoscy Feb 20, 2026
7481d6e
Merge branch 'PyLabRobot:main' into odtc
cmoscy Feb 22, 2026
a1e914e
Merge branch 'PyLabRobot:main' into odtc
cmoscy Mar 3, 2026
bfe348a
Ruff formatting
cmoscy Mar 3, 2026
1c70ac5
Merge branch 'PyLabRobot:main' into odtc
cmoscy Mar 6, 2026
e2fb738
Updated Ruff 0.15.4 Formatting
cmoscy Mar 6, 2026
84c3382
Import fix
cmoscy Mar 6, 2026
ed5a281
Merge branch 'main' into odtc
rickwierenga Mar 10, 2026
2b38fb3
Merge branch 'main' into odtc
rickwierenga Mar 15, 2026
0a4d1bb
simplify ODTC variant to Literal[96, 384], normalize device codes at …
rickwierenga Mar 15, 2026
9f33db6
delete ODTCThermocycler, use Thermocycler + ODTCBackend directly
rickwierenga Mar 15, 2026
8cbabd9
remove **kwargs from ThermocyclerBackend ABC and unused backend kwargs
rickwierenga Mar 15, 2026
c336ed4
derive variant_name from variant, remove redundant field
rickwierenga Mar 15, 2026
8fb50c5
simplify variant_name and STATE_ALLOWABILITY
rickwierenga Mar 15, 2026
5ca6c35
deduplicate SOAP templates, move _handle_return_code to base class
rickwierenga Mar 15, 2026
a7fb695
ODTC reuses base InhecoSiLAInterface send_command
rickwierenga Mar 16, 2026
893ae2b
delete execute() dispatcher, inline methods, add event_receiver_uri p…
rickwierenga Mar 16, 2026
5cbe103
simplify ODTCHardwareConstraints, delete resolve_protocol_name, renam…
rickwierenga Mar 16, 2026
e631731
delete dead helpers, merge duplicate functions, simplify conversions
rickwierenga Mar 16, 2026
b2711aa
fix link-local IP detection, add async timeout, harden setup
rickwierenga Mar 16, 2026
d474ee5
fix link-local IP, never return 500, 60s timeout for non-ExecuteMethod
rickwierenga Mar 16, 2026
a9e9d70
delete local state tracking, add request_status to base SiLA interface
rickwierenga Mar 16, 2026
8c65de8
delete _normalize_command_name, use canonical SiLA command names only
rickwierenga Mar 16, 2026
8bfe555
delete ProtocolList, list_protocols, list_methods; consolidate into O…
rickwierenga Mar 17, 2026
4e3e1ef
delete start_command, _run_async_command, ODTCCommand, SYNCHRONOUS_CO…
rickwierenga Mar 17, 2026
26e0d27
move return code handling from _post_command into send_command
rickwierenga Mar 17, 2026
07bc94b
delete client-side parallelism enforcement, let device handle it
rickwierenga Mar 17, 2026
360d367
add send_command_async as primitive, send_command is thin wrapper on top
rickwierenga Mar 17, 2026
c582f34
delete ODTCExecution, use fire-and-forget like other thermocyclers
rickwierenga Mar 17, 2026
169002b
run_protocol auto-runs premethod, fix wait=True for premethods, simpl…
rickwierenga Mar 19, 2026
0669a0a
delete ODTC-specific abstractions, use standard PLR patterns
rickwierenga Mar 19, 2026
a0dd1c8
simplify ODTC backend: standard PLR patterns, cleaner abstractions
rickwierenga Mar 19, 2026
92c2248
split odtc_model.py into model/protocol/xml, move DataEvent storage t…
rickwierenga Mar 19, 2026
c4c3bf3
Merge branch 'main' into odtc
rickwierenga Mar 20, 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
375 changes: 283 additions & 92 deletions pylabrobot/storage/inheco/scila/inheco_sila_interface.py

Large diffs are not rendered by default.

15 changes: 7 additions & 8 deletions pylabrobot/storage/inheco/scila/scila_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from typing import Any, Dict, Literal, Optional

from pylabrobot.machines.backend import MachineBackend
from pylabrobot.storage.inheco.scila.inheco_sila_interface import InhecoSiLAInterface
from pylabrobot.storage.inheco.scila.inheco_sila_interface import InhecoSiLAInterface, SiLAState


def _parse_scalar(text: Optional[str], tag: str) -> object:
Expand Down Expand Up @@ -46,18 +46,17 @@ async def stop(self) -> None:
await self._sila_interface.close()

async def _reset_and_initialize(self) -> None:
event_uri = f"http://{self._sila_interface.client_ip}:{self._sila_interface.bound_port}/"
await self._sila_interface.send_command(
command="Reset", deviceId="MyController", eventReceiverURI=event_uri, simulationMode=False
command="Reset",
deviceId="MyController",
eventReceiverURI=self._sila_interface.event_receiver_uri,
simulationMode=False,
)

await self._sila_interface.send_command("Initialize")

async def request_status(self) -> str:
# GetStatus returns synchronously (return_code 1 = immediate dict), unlike other commands
# which return asynchronously (return_code 2 = XML via callback).
resp = await self._sila_interface.send_command("GetStatus")
return resp.get("GetStatusResponse", {}).get("state", "Unknown") # type: ignore
async def request_status(self) -> SiLAState:
return await self._sila_interface.request_status()

async def request_liquid_level(self) -> str:
root = await self._sila_interface.send_command("GetLiquidLevel")
Expand Down
37 changes: 33 additions & 4 deletions pylabrobot/storage/inheco/scila/scila_backend_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import xml.etree.ElementTree as ET
from unittest.mock import AsyncMock, patch

from pylabrobot.storage.inheco.scila.inheco_sila_interface import InhecoSiLAInterface
from pylabrobot.storage.inheco.scila.inheco_sila_interface import InhecoSiLAInterface, SiLAState
from pylabrobot.storage.inheco.scila.scila_backend import SCILABackend


Expand All @@ -13,6 +13,7 @@ def setUp(self):
self.mock_sila_interface = AsyncMock(spec=InhecoSiLAInterface)
self.mock_sila_interface.bound_port = 80
self.mock_sila_interface.client_ip = "127.0.0.1"
self.mock_sila_interface.event_receiver_uri = "http://127.0.0.1:80/"
self.MockInhecoSiLAInterface.return_value = self.mock_sila_interface
self.backend = SCILABackend(scila_ip="127.0.0.1")

Expand All @@ -35,10 +36,10 @@ async def test_stop(self):
self.mock_sila_interface.close.assert_called_once()

async def test_request_status(self):
self.mock_sila_interface.send_command.return_value = {"GetStatusResponse": {"state": "standBy"}}
self.mock_sila_interface.request_status = AsyncMock(return_value=SiLAState.STANDBY)
status = await self.backend.request_status()
self.assertEqual(status, "standBy")
self.mock_sila_interface.send_command.assert_called_with("GetStatus")
self.assertEqual(status, SiLAState.STANDBY)
self.mock_sila_interface.request_status.assert_called_once()

async def test_request_liquid_level(self):
self.mock_sila_interface.send_command.return_value = ET.fromstring(
Expand Down Expand Up @@ -232,5 +233,33 @@ def test_deserialize_no_client_ip(self):
self.MockInhecoSiLAInterface.assert_called_with(client_ip=None, machine_ip="169.254.1.117")


class TestInhecoSiLAInterfaceLocking(unittest.IsolatedAsyncioTestCase):
"""Tests for lock_device / unlock_device on InhecoSiLAInterface."""

def setUp(self):
self.interface = InhecoSiLAInterface(machine_ip="192.168.1.100", client_ip="127.0.0.1")

async def test_lock_device(self):
self.interface.send_command = AsyncMock() # type: ignore[method-assign]
await self.interface.lock_device("my_lock_id")
self.interface.send_command.assert_called_once()
call_kwargs = self.interface.send_command.call_args[1]
self.assertEqual(call_kwargs["lockId"], "my_lock_id")
self.assertEqual(self.interface._lock_id, "my_lock_id")

async def test_unlock_device(self):
self.interface._lock_id = "my_lock_id"
self.interface.send_command = AsyncMock() # type: ignore[method-assign]
await self.interface.unlock_device()
self.interface.send_command.assert_called_once_with("UnlockDevice")
self.assertIsNone(self.interface._lock_id)

async def test_unlock_device_not_locked(self):
self.interface._lock_id = None
with self.assertRaises(RuntimeError) as cm:
await self.interface.unlock_device()
self.assertIn("not locked", str(cm.exception))


if __name__ == "__main__":
unittest.main()
29 changes: 23 additions & 6 deletions pylabrobot/thermocycling/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,31 @@ async def deactivate_lid(self):
"""Deactivate thermocycler lid."""

@abstractmethod
async def run_protocol(self, protocol: Protocol, block_max_volume: float):
async def run_protocol(
self,
protocol: Protocol,
block_max_volume: float,
):
"""Execute thermocycler protocol run.

Args:
protocol: Protocol object containing stages with steps and repeats.
block_max_volume: Maximum block volume (µL) for safety.
"""

async def run_stored_protocol(self, name: str, wait: bool = False):
"""Execute a stored protocol by name (optional; backends that support it override).

Args:
name: Name of the stored protocol to run.
wait: If False (default), start and return an execution handle. If True,
block until done then return the (completed) handle.

Raises:
NotImplementedError: This backend does not support running stored protocols by name.
"""
raise NotImplementedError("This backend does not support running stored protocols by name.")

@abstractmethod
async def get_block_current_temperature(self) -> List[float]:
"""Get the current block temperature zones in °C."""
Expand Down Expand Up @@ -79,20 +96,20 @@ async def get_block_status(self) -> BlockStatus:

@abstractmethod
async def get_hold_time(self) -> float:
"""Get remaining hold time in seconds."""
"""Get remaining hold time in seconds. Return 0 when no profile is running."""

@abstractmethod
async def get_current_cycle_index(self) -> int:
"""Get the zero-based index of the current cycle."""
"""Get the zero-based index of the current cycle. Return 0 when no profile is running."""

@abstractmethod
async def get_total_cycle_count(self) -> int:
"""Get the total cycle count."""
"""Get the total cycle count. Return 0 when no profile is running."""

@abstractmethod
async def get_current_step_index(self) -> int:
"""Get the zero-based index of the current step within the cycle."""
"""Get the zero-based index of the current step within the cycle. Return 0 when no profile is running."""

@abstractmethod
async def get_total_step_count(self) -> int:
"""Get the total number of steps in the current cycle."""
"""Get the total number of steps in the current cycle. Return 0 when no profile is running."""
2 changes: 1 addition & 1 deletion pylabrobot/thermocycling/chatterbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ async def deactivate_lid(self):
print("Deactivating lid.")
self._state.lid_target = None

async def run_protocol(self, protocol: Protocol, block_max_volume: float):
async def run_protocol(self, protocol: Protocol, block_max_volume: float, **kwargs):
"""Run a protocol with stages and repeats."""
print("Running protocol:")

Expand Down
Loading
Loading