Skip to content

Commit 07e652d

Browse files
committed
fix(azenta): handle IntelliXcap V49 command behavior
1 parent 9e0d594 commit 07e652d

2 files changed

Lines changed: 108 additions & 56 deletions

File tree

pylabrobot/azenta/fluidx/intellixcap96.py

Lines changed: 78 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,9 @@
3131
READY = "k"
3232
CARTRIDGE_EJECT = "c"
3333
CARTRIDGE_LOAD = "C"
34-
TRAY_EXTEND = "t"
35-
TRAY_RETRACT = "T"
34+
# Newer-firmware tray opcodes, intentionally not sent until verified there:
35+
# TRAY_EXTEND = "t"
36+
# TRAY_RETRACT = "T"
3637
TRAY_STEP_OUT = "s"
3738
TRAY_STEP_IN = "S"
3839
CARTRIDGE_COUNTER_RESET = "X"
@@ -457,22 +458,35 @@ class FluidXIntelliXcap96:
457458
to select a compatible cartridge.
458459
459460
Single-character commands, each written followed by ETX:
460-
a request status e extended status bitmask
461-
h start decapping E cartridge profile settings
462-
i start recapping V firmware versions
463-
b release held caps into a carrier N cartridge profile/cycles/serial
464-
f open the tray 8 latched error code
465-
g close the tray c eject the cartridge onto the tray
466-
Z home all axes C load the cartridge from the tray
467-
j enter standby X reset the cartridge cycle counter
468-
k leave standby (ready) t extend the tray to S127
469-
l light curtain detection off T retract the tray to S3
470-
L light curtain detection on s step the tray out by S88
471-
d dry-run mode on S step the tray in by S88
472-
D dry-run mode off Q force a decap retry
473-
+ safety door on 5 eject held caps (manual mode)
474-
- safety door off 6 home the cap head (manual mode)
475-
z initialize keeping caps on pins 7 open the safety door (manual mode)
461+
a request status
462+
b release held caps into a carrier
463+
c eject the cartridge onto the tray
464+
d dry-run mode on
465+
D dry-run mode off
466+
e extended status bitmask
467+
E cartridge profile settings
468+
f open the tray
469+
g close the tray
470+
h start decapping
471+
i start recapping
472+
j enter standby
473+
k leave standby (ready)
474+
l light curtain detection off
475+
L light curtain detection on
476+
N cartridge profile/cycles/serial
477+
Q force a decap retry
478+
s step the tray out by S88
479+
S step the tray in by S88
480+
V firmware versions
481+
X reset the cartridge cycle counter
482+
Z home all axes
483+
z initialize keeping caps on pins
484+
+ safety door on
485+
- safety door off
486+
5 eject held caps (manual mode)
487+
6 home the cap head (manual mode)
488+
7 open the safety door (manual mode)
489+
8 latched error code
476490
477491
A command is answered with an ACK frame (0x06), a ``<cmd>OK`` echo frame, and a
478492
result frame. The status word, in the priority the firmware reports it, is
@@ -495,11 +509,17 @@ class FluidXIntelliXcap96:
495509
an operation issued while the device is latched in error homes to recover and
496510
then proceeds.
497511
498-
Verified against hardware: connection, status, tray open/close, home,
499-
standby/ready, the decap error/recovery path, and decap/recap with a loaded
500-
0.5 mL rack, including release of held caps with ``waste``. The remaining
501-
commands are implemented from the RS232 command list (Azenta part 386063 Rev.
502-
A) and have not been exercised on an instrument.
512+
Newer firmware documents ``t``/``T`` commands for absolute tray travel
513+
between S3 and S127. Unit firmware V49 ignored both commands completely, so
514+
:meth:`extend_tray` and :meth:`retract_tray` raise
515+
:class:`NotImplementedError` without writing to the instrument.
516+
517+
Verified against hardware: connection, queries, tray open/close, home,
518+
standby/ready, settings, cartridge eject/load, the decap error/recovery path,
519+
forced decap retry, and decap/recap with a loaded 0.5 mL rack, including
520+
release of held caps with ``waste``. Unit firmware V49 did not acknowledge
521+
the four tray travel commands. The cartridge counter reset and manual
522+
recovery commands have not been exercised on an instrument.
503523
504524
See the Azenta IntelliXcap user manual for the required carrier and physical
505525
setup:
@@ -547,10 +567,10 @@ def __init__(
547567
async def setup(self) -> None:
548568
await self.io.setup()
549569
logger.warning(
550-
"[IntelliXcap96 %s] most of this driver's commands are implemented from the RS232 command "
551-
"list and have not been run on an instrument. The queries, tray open/close, home, "
552-
"standby/ready and decap/recap/waste are hardware-verified; the tray travel, cartridge, "
553-
"settings and manual recovery commands are not. Please report back so this can be updated.",
570+
"[IntelliXcap96 %s] connection, queries, tray open/close, home, standby/ready, settings, "
571+
"cartridge eject/load, forced decap retry and decap/recap/waste are hardware-verified. "
572+
"Unit firmware V49 does not acknowledge the tray travel commands. The cartridge counter "
573+
"reset and manual recovery commands are not hardware-verified.",
554574
self.io.port,
555575
)
556576
status = await self.request_status()
@@ -920,6 +940,8 @@ async def _tray_move(self, command: str, name: str, timeout: float) -> None:
920940
921941
These acknowledge and answer with the same ``<cmd>OK`` frame, so a completed
922942
move is a second echo and a failed one is ``<cmd>ERROR``.
943+
944+
Unit firmware V49 does not acknowledge these commands.
923945
"""
924946
await self._ensure_ready()
925947
echo = f"{command}OK"
@@ -934,14 +956,26 @@ async def _tray_move(self, command: str, name: str, timeout: float) -> None:
934956
async def extend_tray(self, timeout: float = 15.0) -> None:
935957
"""Move the tray from the load position (S3) out to the extended position (S127).
936958
937-
Presents decapped tubes further out of the instrument. Fails unless the tray
938-
is at the load position, so open the tray first.
959+
This command is documented for newer firmware but is not implemented here:
960+
unit firmware V49 ignored it completely. The method raises without writing
961+
to the instrument.
939962
"""
940-
await self._tray_move(TRAY_EXTEND, "Extending the tray", timeout)
963+
raise NotImplementedError(
964+
"extend_tray() uses the newer-firmware 't' command, which is not supported by "
965+
"IntelliXcap unit firmware V49"
966+
)
941967

942968
async def retract_tray(self, timeout: float = 15.0) -> None:
943-
"""Move the tray from the extended position (S127) back to the load position (S3)."""
944-
await self._tray_move(TRAY_RETRACT, "Retracting the tray", timeout)
969+
"""Move the tray from the extended position (S127) back to the load position (S3).
970+
971+
This command is documented for newer firmware but is not implemented here:
972+
unit firmware V49 ignored it completely. The method raises without writing
973+
to the instrument.
974+
"""
975+
raise NotImplementedError(
976+
"retract_tray() uses the newer-firmware 'T' command, which is not supported by "
977+
"IntelliXcap unit firmware V49"
978+
)
945979

946980
async def step_tray_out(self, timeout: float = 15.0) -> None:
947981
"""Move the tray further out by the step distance in setpoint 88.
@@ -1100,7 +1134,9 @@ async def retry_decap(self, timeout: float = 60.0) -> None:
11001134
The command list documents only that this command is used "after a
11011135
successful Decap" and "requires lightcurtain OFF". That those are the same
11021136
condition is inferred from the automatic-retry and error-detection commands
1103-
rather than stated by the vendor.
1137+
rather than stated by the vendor. The instrument finishes in
1138+
``StatusRECAP`` because it is still holding the caps for a subsequent
1139+
:meth:`recap`.
11041140
"""
11051141
await self._ensure_ready()
11061142
frames = await self.send_command(RETRY_DECAP)
@@ -1110,7 +1146,7 @@ async def retry_decap(self, timeout: float = 60.0) -> None:
11101146
timeout,
11111147
"Retrying the decap",
11121148
"RetryDecapWasNotSuccesful",
1113-
("StatusOK",),
1149+
("StatusRECAP",),
11141150
done_frames=("DecapDONE",),
11151151
)
11161152
logger.info("[IntelliXcap96 %s] decap retry complete", self.io.port)
@@ -1262,8 +1298,8 @@ async def reset_cartridge_counter(self) -> None:
12621298
"""Reset the installed cartridge's cycle counter to zero."""
12631299
name = "Resetting the cartridge counter"
12641300
frames = await self.send_command(CARTRIDGE_COUNTER_RESET)
1265-
self._require_accepted(CARTRIDGE_COUNTER_RESET, frames, name, idempotent=True)
1266-
self._require_answer(frames, "CarResetDONE", name)
1301+
if self._require_accepted(CARTRIDGE_COUNTER_RESET, frames, name, idempotent=True):
1302+
self._require_answer(frames, "CarResetDONE", name)
12671303
logger.info("[IntelliXcap96 %s] cartridge counter reset", self.io.port)
12681304

12691305
# === Settings ===
@@ -1278,8 +1314,8 @@ async def set_error_detection_enabled(self, enabled: bool) -> None:
12781314
command = ERROR_DETECTION_ON if enabled else ERROR_DETECTION_OFF
12791315
name = "Enabling error detection" if enabled else "Disabling error detection"
12801316
frames = await self.send_command(command)
1281-
self._require_accepted(command, frames, name, idempotent=True)
1282-
self._require_answer(frames, "ErrorDetectON" if enabled else "ErrorDetectOFF", name)
1317+
if self._require_accepted(command, frames, name, idempotent=True):
1318+
self._require_answer(frames, "ErrorDetectON" if enabled else "ErrorDetectOFF", name)
12831319
logger.info("[IntelliXcap96 %s] error detection %s", self.io.port, "on" if enabled else "off")
12841320

12851321
async def set_dry_run_enabled(self, enabled: bool) -> None:
@@ -1293,8 +1329,8 @@ async def set_dry_run_enabled(self, enabled: bool) -> None:
12931329
command = DRY_RUN_ON if enabled else DRY_RUN_OFF
12941330
name = "Enabling dry-run mode" if enabled else "Disabling dry-run mode"
12951331
frames = await self.send_command(command)
1296-
self._require_accepted(command, frames, name, idempotent=True)
1297-
self._require_answer(frames, "DryRunON" if enabled else "DryRunOFF", name)
1332+
if self._require_accepted(command, frames, name, idempotent=True):
1333+
self._require_answer(frames, "DryRunON" if enabled else "DryRunOFF", name)
12981334
logger.info("[IntelliXcap96 %s] dry-run mode %s", self.io.port, "on" if enabled else "off")
12991335

13001336
async def set_safety_door_enabled(self, enabled: bool) -> None:
@@ -1306,8 +1342,8 @@ async def set_safety_door_enabled(self, enabled: bool) -> None:
13061342
command = SAFETY_DOOR_ENABLE if enabled else SAFETY_DOOR_DISABLE
13071343
name = "Enabling the safety door" if enabled else "Disabling the safety door"
13081344
frames = await self.send_command(command)
1309-
self._require_accepted(command, frames, name, idempotent=True)
1310-
self._require_answer(frames, "DoorONDONE" if enabled else "DoorOFFDONE", name)
1345+
if self._require_accepted(command, frames, name, idempotent=True):
1346+
self._require_answer(frames, "DoorONDONE" if enabled else "DoorOFFDONE", name)
13111347
logger.info("[IntelliXcap96 %s] safety door %s", self.io.port, "on" if enabled else "off")
13121348

13131349
# === Manual recovery ===

pylabrobot/azenta/fluidx/intellixcap96_tests.py

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -270,17 +270,6 @@ async def test_close_tray_preserves_caps_held_state(self):
270270
await device.close_tray()
271271
self.assertEqual(self._written(device), ["a", "g", "a", "a"])
272272

273-
async def test_extend_tray(self):
274-
# A tray move acknowledges and answers with the same echo, so success is two.
275-
device = self._make([status("StatusOK"), [ACK, "tOK", "tOK"]])
276-
await device.extend_tray()
277-
self.assertEqual(self._written(device), ["a", "t"])
278-
279-
async def test_retract_tray(self):
280-
device = self._make([status("StatusOK"), [ACK, "TOK", "TOK"]])
281-
await device.retract_tray()
282-
self.assertEqual(self._written(device), ["a", "T"])
283-
284273
async def test_step_tray_out_and_in(self):
285274
device = self._make(
286275
[
@@ -449,10 +438,17 @@ async def test_manual_halt_during_motion_fails_immediately(self):
449438
self.assertEqual(ctx.exception.error_code, 167)
450439
self.assertFalse(ctx.exception.recoverable)
451440

452-
async def test_retry_decap_is_acknowledged_as_a_decap(self):
453-
device = self._make([status("StatusOK"), [ACK, "hOK"], [ACK, "aOK", "DecapDONE"]])
441+
async def test_retry_decap_finishes_with_caps_held(self):
442+
device = self._make(
443+
[
444+
status("StatusRECAP"),
445+
[ACK, "hOK"],
446+
status("StatusBUSY"),
447+
status("StatusRECAP"),
448+
]
449+
)
454450
await device.retry_decap()
455-
self.assertEqual(self._written(device), ["a", "Q", "a"])
451+
self.assertEqual(self._written(device), ["a", "Q", "a", "a"])
456452

457453
async def test_recap_blocked_when_not_decapped(self):
458454
device = self._make([status("StatusDecap")])
@@ -565,6 +561,11 @@ async def test_reset_cartridge_counter_requires_its_answer(self):
565561
with self.assertRaises(FluidXError):
566562
await device.reset_cartridge_counter()
567563

564+
async def test_reset_cartridge_counter_is_idempotent(self):
565+
device = self._make([[ACK, "XOK", "CommandIgnore"]])
566+
await device.reset_cartridge_counter()
567+
self.assertEqual(self._written(device), ["X"])
568+
568569
async def test_load_cartridge_reads_the_profile_from_the_end_of_the_motion(self):
569570
# onnOK is documented as an end-of-motion answer, not part of the ack.
570571
device = self._make(
@@ -596,6 +597,11 @@ async def test_set_error_detection_on(self):
596597
await device.set_error_detection_enabled(True)
597598
self.assertEqual(self._written(device), ["L"])
598599

600+
async def test_set_error_detection_is_idempotent(self):
601+
device = self._make([[ACK, "LOK", "CommandIgnore"]])
602+
await device.set_error_detection_enabled(True)
603+
self.assertEqual(self._written(device), ["L"])
604+
599605
async def test_set_dry_run_enabled(self):
600606
device = self._make([[ACK, "dOK", "DryRunON"]])
601607
await device.set_dry_run_enabled(True)
@@ -606,6 +612,11 @@ async def test_set_dry_run_disabled(self):
606612
await device.set_dry_run_enabled(False)
607613
self.assertEqual(self._written(device), ["D"])
608614

615+
async def test_set_dry_run_is_idempotent(self):
616+
device = self._make([[ACK, "DOK", "CommandIgnore"]])
617+
await device.set_dry_run_enabled(False)
618+
self.assertEqual(self._written(device), ["D"])
619+
609620
async def test_setting_raises_when_the_device_does_not_confirm(self):
610621
device = self._make([[ACK, "dOK"]])
611622
with self.assertRaises(FluidXError):
@@ -621,6 +632,11 @@ async def test_set_safety_door_disabled(self):
621632
await device.set_safety_door_enabled(False)
622633
self.assertEqual(self._written(device), ["-"])
623634

635+
async def test_set_safety_door_is_idempotent(self):
636+
device = self._make([[ACK, "+OK", "CommandIgnore"]])
637+
await device.set_safety_door_enabled(True)
638+
self.assertEqual(self._written(device), ["+"])
639+
624640
# === Manual recovery commands ===
625641

626642
async def test_eject_caps_requires_manual_mode(self):

0 commit comments

Comments
 (0)