opentrons 8.7.0a6__py3-none-any.whl → 8.7.0a7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of opentrons might be problematic. Click here for more details.

Files changed (144) hide show
  1. opentrons/_version.py +2 -2
  2. opentrons/drivers/asyncio/communication/serial_connection.py +129 -52
  3. opentrons/drivers/heater_shaker/abstract.py +5 -0
  4. opentrons/drivers/heater_shaker/driver.py +10 -0
  5. opentrons/drivers/heater_shaker/simulator.py +4 -0
  6. opentrons/drivers/thermocycler/abstract.py +6 -0
  7. opentrons/drivers/thermocycler/driver.py +61 -10
  8. opentrons/drivers/thermocycler/simulator.py +6 -0
  9. opentrons/hardware_control/api.py +24 -5
  10. opentrons/hardware_control/backends/controller.py +8 -2
  11. opentrons/hardware_control/backends/ot3controller.py +3 -0
  12. opentrons/hardware_control/backends/ot3simulator.py +2 -1
  13. opentrons/hardware_control/backends/simulator.py +2 -1
  14. opentrons/hardware_control/backends/subsystem_manager.py +5 -2
  15. opentrons/hardware_control/emulation/abstract_emulator.py +6 -4
  16. opentrons/hardware_control/emulation/connection_handler.py +8 -5
  17. opentrons/hardware_control/emulation/heater_shaker.py +12 -3
  18. opentrons/hardware_control/emulation/settings.py +1 -1
  19. opentrons/hardware_control/emulation/thermocycler.py +67 -15
  20. opentrons/hardware_control/module_control.py +82 -8
  21. opentrons/hardware_control/modules/__init__.py +3 -0
  22. opentrons/hardware_control/modules/absorbance_reader.py +11 -4
  23. opentrons/hardware_control/modules/flex_stacker.py +38 -9
  24. opentrons/hardware_control/modules/heater_shaker.py +42 -5
  25. opentrons/hardware_control/modules/magdeck.py +8 -4
  26. opentrons/hardware_control/modules/mod_abc.py +13 -5
  27. opentrons/hardware_control/modules/tempdeck.py +25 -5
  28. opentrons/hardware_control/modules/thermocycler.py +68 -11
  29. opentrons/hardware_control/modules/types.py +20 -1
  30. opentrons/hardware_control/modules/utils.py +11 -4
  31. opentrons/hardware_control/nozzle_manager.py +3 -0
  32. opentrons/hardware_control/ot3api.py +26 -5
  33. opentrons/hardware_control/poller.py +22 -8
  34. opentrons/hardware_control/scripts/update_module_fw.py +5 -0
  35. opentrons/hardware_control/types.py +31 -2
  36. opentrons/legacy_commands/module_commands.py +23 -0
  37. opentrons/legacy_commands/protocol_commands.py +20 -0
  38. opentrons/legacy_commands/types.py +80 -0
  39. opentrons/motion_planning/deck_conflict.py +17 -12
  40. opentrons/motion_planning/waypoints.py +15 -29
  41. opentrons/protocol_api/__init__.py +5 -1
  42. opentrons/protocol_api/_types.py +6 -1
  43. opentrons/protocol_api/core/common.py +3 -1
  44. opentrons/protocol_api/core/engine/_default_labware_versions.py +32 -11
  45. opentrons/protocol_api/core/engine/labware.py +8 -1
  46. opentrons/protocol_api/core/engine/module_core.py +75 -8
  47. opentrons/protocol_api/core/engine/protocol.py +18 -1
  48. opentrons/protocol_api/core/engine/tasks.py +48 -0
  49. opentrons/protocol_api/core/engine/well.py +8 -0
  50. opentrons/protocol_api/core/legacy/legacy_module_core.py +24 -4
  51. opentrons/protocol_api/core/legacy/legacy_protocol_core.py +11 -1
  52. opentrons/protocol_api/core/legacy/legacy_well_core.py +4 -0
  53. opentrons/protocol_api/core/legacy/tasks.py +19 -0
  54. opentrons/protocol_api/core/legacy_simulator/legacy_protocol_core.py +14 -2
  55. opentrons/protocol_api/core/legacy_simulator/tasks.py +19 -0
  56. opentrons/protocol_api/core/module.py +37 -4
  57. opentrons/protocol_api/core/protocol.py +11 -2
  58. opentrons/protocol_api/core/tasks.py +31 -0
  59. opentrons/protocol_api/core/well.py +4 -0
  60. opentrons/protocol_api/labware.py +5 -0
  61. opentrons/protocol_api/module_contexts.py +117 -11
  62. opentrons/protocol_api/protocol_context.py +26 -4
  63. opentrons/protocol_api/robot_context.py +38 -21
  64. opentrons/protocol_api/tasks.py +48 -0
  65. opentrons/protocol_api/validation.py +6 -1
  66. opentrons/protocol_engine/actions/__init__.py +4 -2
  67. opentrons/protocol_engine/actions/actions.py +22 -9
  68. opentrons/protocol_engine/clients/sync_client.py +42 -7
  69. opentrons/protocol_engine/commands/__init__.py +42 -0
  70. opentrons/protocol_engine/commands/absorbance_reader/close_lid.py +2 -15
  71. opentrons/protocol_engine/commands/absorbance_reader/open_lid.py +2 -15
  72. opentrons/protocol_engine/commands/aspirate.py +1 -0
  73. opentrons/protocol_engine/commands/command.py +1 -0
  74. opentrons/protocol_engine/commands/command_unions.py +49 -0
  75. opentrons/protocol_engine/commands/create_timer.py +83 -0
  76. opentrons/protocol_engine/commands/dispense.py +1 -0
  77. opentrons/protocol_engine/commands/drop_tip.py +32 -8
  78. opentrons/protocol_engine/commands/heater_shaker/__init__.py +14 -0
  79. opentrons/protocol_engine/commands/heater_shaker/common.py +20 -0
  80. opentrons/protocol_engine/commands/heater_shaker/set_and_wait_for_shake_speed.py +5 -4
  81. opentrons/protocol_engine/commands/heater_shaker/set_shake_speed.py +136 -0
  82. opentrons/protocol_engine/commands/heater_shaker/set_target_temperature.py +31 -5
  83. opentrons/protocol_engine/commands/movement_common.py +2 -0
  84. opentrons/protocol_engine/commands/pick_up_tip.py +21 -11
  85. opentrons/protocol_engine/commands/set_tip_state.py +97 -0
  86. opentrons/protocol_engine/commands/temperature_module/set_target_temperature.py +38 -7
  87. opentrons/protocol_engine/commands/thermocycler/__init__.py +16 -0
  88. opentrons/protocol_engine/commands/thermocycler/run_extended_profile.py +6 -0
  89. opentrons/protocol_engine/commands/thermocycler/run_profile.py +8 -0
  90. opentrons/protocol_engine/commands/thermocycler/set_target_block_temperature.py +40 -6
  91. opentrons/protocol_engine/commands/thermocycler/set_target_lid_temperature.py +29 -5
  92. opentrons/protocol_engine/commands/thermocycler/start_run_extended_profile.py +191 -0
  93. opentrons/protocol_engine/commands/touch_tip.py +1 -1
  94. opentrons/protocol_engine/commands/unsafe/unsafe_place_labware.py +6 -22
  95. opentrons/protocol_engine/commands/wait_for_tasks.py +98 -0
  96. opentrons/protocol_engine/errors/__init__.py +4 -0
  97. opentrons/protocol_engine/errors/exceptions.py +55 -0
  98. opentrons/protocol_engine/execution/__init__.py +2 -0
  99. opentrons/protocol_engine/execution/command_executor.py +8 -0
  100. opentrons/protocol_engine/execution/create_queue_worker.py +5 -1
  101. opentrons/protocol_engine/execution/labware_movement.py +9 -12
  102. opentrons/protocol_engine/execution/movement.py +2 -0
  103. opentrons/protocol_engine/execution/queue_worker.py +4 -0
  104. opentrons/protocol_engine/execution/run_control.py +8 -0
  105. opentrons/protocol_engine/execution/task_handler.py +157 -0
  106. opentrons/protocol_engine/protocol_engine.py +75 -34
  107. opentrons/protocol_engine/resources/__init__.py +2 -0
  108. opentrons/protocol_engine/resources/concurrency_provider.py +27 -0
  109. opentrons/protocol_engine/resources/deck_configuration_provider.py +7 -0
  110. opentrons/protocol_engine/resources/labware_validation.py +10 -6
  111. opentrons/protocol_engine/state/_well_math.py +60 -18
  112. opentrons/protocol_engine/state/addressable_areas.py +2 -0
  113. opentrons/protocol_engine/state/commands.py +14 -11
  114. opentrons/protocol_engine/state/geometry.py +213 -374
  115. opentrons/protocol_engine/state/labware.py +52 -102
  116. opentrons/protocol_engine/state/labware_origin_math/errors.py +94 -0
  117. opentrons/protocol_engine/state/labware_origin_math/stackup_origin_to_labware_origin.py +1331 -0
  118. opentrons/protocol_engine/state/module_substates/thermocycler_module_substate.py +37 -0
  119. opentrons/protocol_engine/state/modules.py +21 -8
  120. opentrons/protocol_engine/state/motion.py +44 -0
  121. opentrons/protocol_engine/state/state.py +14 -0
  122. opentrons/protocol_engine/state/state_summary.py +2 -0
  123. opentrons/protocol_engine/state/tasks.py +139 -0
  124. opentrons/protocol_engine/state/tips.py +177 -258
  125. opentrons/protocol_engine/state/update_types.py +16 -9
  126. opentrons/protocol_engine/types/__init__.py +9 -3
  127. opentrons/protocol_engine/types/deck_configuration.py +5 -1
  128. opentrons/protocol_engine/types/instrument.py +8 -1
  129. opentrons/protocol_engine/types/labware.py +1 -13
  130. opentrons/protocol_engine/types/module.py +10 -0
  131. opentrons/protocol_engine/types/tasks.py +38 -0
  132. opentrons/protocol_engine/types/tip.py +9 -0
  133. opentrons/protocol_runner/create_simulating_orchestrator.py +29 -2
  134. opentrons/protocol_runner/run_orchestrator.py +18 -2
  135. opentrons/protocols/api_support/definitions.py +1 -1
  136. opentrons/protocols/api_support/types.py +2 -1
  137. opentrons/simulate.py +48 -15
  138. opentrons/system/camera.py +1 -1
  139. {opentrons-8.7.0a6.dist-info → opentrons-8.7.0a7.dist-info}/METADATA +4 -4
  140. {opentrons-8.7.0a6.dist-info → opentrons-8.7.0a7.dist-info}/RECORD +143 -127
  141. opentrons/protocol_engine/state/_labware_origin_math.py +0 -636
  142. {opentrons-8.7.0a6.dist-info → opentrons-8.7.0a7.dist-info}/WHEEL +0 -0
  143. {opentrons-8.7.0a6.dist-info → opentrons-8.7.0a7.dist-info}/entry_points.txt +0 -0
  144. {opentrons-8.7.0a6.dist-info → opentrons-8.7.0a7.dist-info}/licenses/LICENSE +0 -0
opentrons/_version.py CHANGED
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '8.7.0a6'
32
- __version_tuple__ = version_tuple = (8, 7, 0, 'a6')
31
+ __version__ = version = '8.7.0a7'
32
+ __version_tuple__ = version_tuple = (8, 7, 0, 'a7')
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -2,7 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import logging
5
- from typing import Optional, List, Type
5
+ from typing import Type, Literal, AsyncIterator
6
6
 
7
7
  from opentrons.drivers.command_builder import CommandBuilder
8
8
 
@@ -25,7 +25,7 @@ class SerialConnection:
25
25
  port: str,
26
26
  baud_rate: int,
27
27
  timeout: float,
28
- loop: Optional[asyncio.AbstractEventLoop],
28
+ loop: asyncio.AbstractEventLoop | None,
29
29
  reset_buffer_before_write: bool,
30
30
  ) -> AsyncSerial:
31
31
  return await AsyncSerial.create(
@@ -43,11 +43,11 @@ class SerialConnection:
43
43
  baud_rate: int,
44
44
  timeout: float,
45
45
  ack: str,
46
- name: Optional[str] = None,
46
+ name: str | None = None,
47
47
  retry_wait_time_seconds: float = 0.1,
48
- loop: Optional[asyncio.AbstractEventLoop] = None,
49
- error_keyword: Optional[str] = None,
50
- alarm_keyword: Optional[str] = None,
48
+ loop: asyncio.AbstractEventLoop | None = None,
49
+ error_keyword: str | None = None,
50
+ alarm_keyword: str | None = None,
51
51
  reset_buffer_before_write: bool = False,
52
52
  error_codes: Type[BaseErrorCode] = DefaultErrorCodes,
53
53
  ) -> "SerialConnection":
@@ -133,7 +133,7 @@ class SerialConnection:
133
133
  self._error_codes = error_codes
134
134
 
135
135
  async def send_command(
136
- self, command: CommandBuilder, retries: int = 0, timeout: Optional[float] = None
136
+ self, command: CommandBuilder, retries: int = 0, timeout: float | None = None
137
137
  ) -> str:
138
138
  """
139
139
  Send a command and return the response.
@@ -165,7 +165,7 @@ class SerialConnection:
165
165
  await self._serial.write(data=encoded_command)
166
166
 
167
167
  async def send_data(
168
- self, data: str, retries: int = 0, timeout: Optional[float] = None
168
+ self, data: str, retries: int = 0, timeout: float | None = None
169
169
  ) -> str:
170
170
  """
171
171
  Send data and return the response.
@@ -184,7 +184,7 @@ class SerialConnection:
184
184
  ):
185
185
  return await self._send_data(data=data, retries=retries)
186
186
 
187
- async def _send_data(self, data: str, retries: int = 0) -> str:
187
+ async def _send_data(self, data: str, retries: int) -> str:
188
188
  """
189
189
  Send data and return the response.
190
190
 
@@ -351,14 +351,14 @@ class AsyncResponseSerialConnection(SerialConnection):
351
351
  baud_rate: int,
352
352
  timeout: float,
353
353
  ack: str,
354
- name: Optional[str] = None,
354
+ name: str | None = None,
355
355
  retry_wait_time_seconds: float = 0.1,
356
- loop: Optional[asyncio.AbstractEventLoop] = None,
357
- error_keyword: Optional[str] = None,
358
- alarm_keyword: Optional[str] = None,
356
+ loop: asyncio.AbstractEventLoop | None = None,
357
+ error_keyword: str | None = None,
358
+ alarm_keyword: str | None = None,
359
359
  reset_buffer_before_write: bool = False,
360
360
  error_codes: Type[BaseErrorCode] = DefaultErrorCodes,
361
- async_error_ack: Optional[str] = None,
361
+ async_error_ack: str | None = None,
362
362
  number_of_retries: int = 0,
363
363
  ) -> AsyncResponseSerialConnection:
364
364
  """
@@ -461,8 +461,29 @@ class AsyncResponseSerialConnection(SerialConnection):
461
461
  self._alarm_keyword = alarm_keyword.lower()
462
462
  self._async_error_ack = async_error_ack.lower()
463
463
 
464
+ async def send_multiack_command(
465
+ self,
466
+ command: CommandBuilder,
467
+ retries: int = 0,
468
+ timeout: float | None = None,
469
+ acks: int = 1,
470
+ ) -> list[str]:
471
+ """Send a command and return the responses.
472
+
473
+ Some commands result in multiple responses; collate them and return them all.
474
+
475
+ Args:
476
+ command: A command builder.
477
+ retries: number of times to retry in case of timeout
478
+ timeout: optional override of default timeout in seconds
479
+ acks: the number of acks to expect
480
+ """
481
+ return await self.send_data_multiack(
482
+ data=command.build(), retries=retries, timeout=timeout, acks=acks
483
+ )
484
+
464
485
  async def send_command(
465
- self, command: CommandBuilder, retries: int = 0, timeout: Optional[float] = None
486
+ self, command: CommandBuilder, retries: int = 0, timeout: float | None = None
466
487
  ) -> str:
467
488
  """
468
489
  Send a command and return the response.
@@ -482,8 +503,19 @@ class AsyncResponseSerialConnection(SerialConnection):
482
503
  timeout=timeout,
483
504
  )
484
505
 
506
+ async def send_data_multiack(
507
+ self, data: str, retries: int = 0, timeout: float | None = None, acks: int = 1
508
+ ) -> list[str]:
509
+ """Send data and return all responses."""
510
+ async with super().send_data_lock, self._serial.timeout_override(
511
+ "timeout", timeout
512
+ ):
513
+ return await self._send_data_multiack(
514
+ data=data, retries=retries or self._number_of_retries, acks=acks
515
+ )
516
+
485
517
  async def send_data(
486
- self, data: str, retries: int = 0, timeout: Optional[float] = None
518
+ self, data: str, retries: int = 0, timeout: float | None = None
487
519
  ) -> str:
488
520
  """
489
521
  Send data and return the response.
@@ -504,54 +536,99 @@ class AsyncResponseSerialConnection(SerialConnection):
504
536
  data=data, retries=retries or self._number_of_retries
505
537
  )
506
538
 
507
- async def _send_data(self, data: str, retries: int = 0) -> str:
539
+ async def _consume_responses(
540
+ self, acks: int
541
+ ) -> AsyncIterator[tuple[Literal["response", "error", "empty-unknown"], bytes]]:
542
+ while acks > 0:
543
+ data = await self._serial.read_until(match=self._ack)
544
+ log.debug(f"{self._name}: Read <- {data!r}")
545
+ if self._async_error_ack.encode() in data:
546
+ yield "error", data
547
+ elif self._ack in data:
548
+ yield "response", data
549
+ acks -= 1
550
+ else:
551
+ # A read timeout, end
552
+ yield "empty-unknown", data
553
+
554
+ async def _send_one_retry(self, data: str, acks: int) -> list[str]:
555
+ data_encode = data.encode("utf-8")
556
+ log.debug(f"{self._name}: Write -> {data_encode!r}")
557
+ await self._serial.write(data=data_encode)
558
+
559
+ command_acks: list[bytes] = []
560
+ async_errors: list[bytes] = []
561
+ # consume responses before raising so we don't raise and orphan
562
+ # a response in the buffer
563
+ async for response_type, response in self._consume_responses(acks):
564
+ if response_type == "error":
565
+ async_errors.append(response)
566
+ elif response_type == "response":
567
+ command_acks.append(response)
568
+ else:
569
+ break
570
+
571
+ for async_error in async_errors:
572
+ # Remove ack from response
573
+ ackless_response = async_error.replace(self._ack, b"")
574
+ str_response = self.process_raw_response(
575
+ command=data, response=ackless_response.decode()
576
+ )
577
+ self.raise_on_error(response=str_response, request=data)
578
+
579
+ ackless_responses: list[str] = []
580
+ for command_ack in command_acks:
581
+ # Remove ack from response
582
+ ackless_response = command_ack.replace(self._ack, b"")
583
+ str_response = self.process_raw_response(
584
+ command=data, response=ackless_response.decode()
585
+ )
586
+ self.raise_on_error(response=str_response, request=data)
587
+ ackless_responses.append(str_response)
588
+ return ackless_responses
589
+
590
+ async def _send_data_multiack(
591
+ self, data: str, retries: int, acks: int
592
+ ) -> list[str]:
508
593
  """
509
- Send data and return the response.
594
+ Send data and return the response(s).
510
595
 
511
596
  Args:
512
597
  data: The data to send.
513
598
  retries: number of times to retry in case of timeout
599
+ acks: The number of expected command responses
514
600
 
515
- Returns: The command response
601
+ This function retries (resends the command) up to (retries) times, and waits
602
+ for (acks) responses. It also listens for async errors. These are an older
603
+ mechanism where at the moment an error occurs, some modules will send a message
604
+ like async error ERR:202:whatever
516
605
 
517
- Raises: SerialException
518
- """
519
- data_encode = data.encode()
520
- retries = retries or self._number_of_retries
606
+ This function will detect async error messages if they were sent before it
607
+ sent the command or if they are sent before the final ack for the command is
608
+ sent. It will not catch async errors otherwise.
521
609
 
522
- for retry in range(retries + 1):
523
- log.debug(f"{self._name}: Write -> {data_encode!r}")
524
- await self._serial.write(data=data_encode)
610
+ This function will always try and consume all the acknowledgements specified for
611
+ its command if it sends the command, even if an async error happens in between.
525
612
 
526
- response: List[bytes] = []
527
- response.append(await self._serial.read_until(match=self._ack))
528
- log.debug(f"{self._name}: Read <- {response[-1]!r}")
529
-
530
- while self._async_error_ack.encode() in response[-1].lower():
531
- # check for multiple a priori async errors
532
- response.append(await self._serial.read_until(match=self._ack))
533
- log.debug(f"{self._name}: Read <- {response[-1]!r}")
534
-
535
- for r in response:
536
- if self._async_error_ack.encode() in r:
537
- # Remove ack from response
538
- ackless_response = r.replace(self._ack, b"")
539
- str_response = self.process_raw_response(
540
- command=data, response=ackless_response.decode()
541
- )
542
- self.raise_on_error(response=str_response, request=data)
613
+ This should all work together to make sure that there aren't any leftover acks
614
+ after the function ends, which could lead to the read/write mechanics getting out
615
+ of sync.
543
616
 
544
- if self._ack in response[-1]:
545
- # Remove ack from response
546
- ackless_response = response[-1].replace(self._ack, b"")
547
- str_response = self.process_raw_response(
548
- command=data, response=ackless_response.decode()
549
- )
550
- self.raise_on_error(response=str_response, request=data)
551
- return str_response
617
+ Returns: The command responses
552
618
 
553
- log.info(f"{self._name}: retry number {retry}/{retries}")
619
+ Raises: SerialException from an error ack to this command or an async error.
620
+ """
621
+ retries = retries or self._number_of_retries
622
+ responses: list[str] = []
554
623
 
624
+ for retry in range(retries + 1):
625
+ responses = await self._send_one_retry(data, acks)
626
+ if responses:
627
+ return responses
628
+ log.info(f"{self._name}: retry number {retry}/{retries}")
555
629
  await self.on_retry()
556
630
 
557
631
  raise NoResponse(port=self._port, command=data)
632
+
633
+ async def _send_data(self, data: str, retries: int) -> str:
634
+ return (await self._send_data_multiack(data, retries, 1))[0]
@@ -74,3 +74,8 @@ class AbstractHeaterShakerDriver(ABC):
74
74
  async def enter_programming_mode(self) -> None:
75
75
  """Reboot into programming mode"""
76
76
  ...
77
+
78
+ @abstractmethod
79
+ async def get_error_state(self) -> None:
80
+ """Raise if the module is in an error state."""
81
+ ...
@@ -27,6 +27,7 @@ class GCODE(str, Enum):
27
27
  GET_LABWARE_LATCH_STATE = "M241"
28
28
  DEACTIVATE_HEATER = "M106"
29
29
  GET_RESET_REASON = "M114"
30
+ GET_ERROR_STATE = "M411"
30
31
 
31
32
 
32
33
  HS_BAUDRATE = 115200
@@ -202,3 +203,12 @@ class HeaterShakerDriver(AbstractHeaterShakerDriver):
202
203
  gcode=GCODE.DEACTIVATE_HEATER
203
204
  )
204
205
  await self._connection.send_command(command=c, retries=DEFAULT_COMMAND_RETRIES)
206
+
207
+ async def get_error_state(self) -> None:
208
+ """Raise if the module is in an error state."""
209
+ await self._connection.send_multiack_command(
210
+ command=CommandBuilder(terminator=HS_COMMAND_TERMINATOR).add_gcode(
211
+ gcode=GCODE.GET_ERROR_STATE
212
+ ),
213
+ acks=2,
214
+ )
@@ -92,3 +92,7 @@ class SimulatingDriver(AbstractHeaterShakerDriver):
92
92
  @ensure_yield
93
93
  async def enter_programming_mode(self) -> None:
94
94
  pass
95
+
96
+ @ensure_yield
97
+ async def get_error_state(self) -> None:
98
+ return
@@ -55,6 +55,7 @@ class AbstractThermocyclerDriver(ABC):
55
55
  temp: float,
56
56
  hold_time: Optional[float] = None,
57
57
  volume: Optional[float] = None,
58
+ ramp_rate: Optional[float] = None,
58
59
  ) -> None:
59
60
  """Send set plate temperature command"""
60
61
  ...
@@ -97,3 +98,8 @@ class AbstractThermocyclerDriver(ABC):
97
98
  async def jog_lid(self, angle: float) -> None:
98
99
  """Send the Jog Lid command."""
99
100
  ...
101
+
102
+ @abstractmethod
103
+ async def get_error_state(self) -> None:
104
+ """Raise if the thermocycler is in an error state."""
105
+ ...
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  import asyncio
4
4
  import logging
5
5
  from enum import Enum
6
- from typing import Optional, Dict, Union
6
+ from typing import Optional, Dict, Union, TypeVar, Generic
7
7
 
8
8
  from opentrons.drivers import utils
9
9
  from opentrons.drivers.command_builder import CommandBuilder
@@ -36,6 +36,7 @@ class GCODE(str, Enum):
36
36
  DEVICE_INFO = "M115"
37
37
  GET_RESET_REASON = "M114"
38
38
  ENTER_PROGRAMMING = "dfu"
39
+ GET_ERROR_STATE = "M411"
39
40
 
40
41
 
41
42
  LID_TARGET_DEFAULT = 105 # Degree celsius (floats)
@@ -73,7 +74,7 @@ class ThermocyclerDriverFactory:
73
74
  @staticmethod
74
75
  async def create(
75
76
  port: str, loop: Optional[asyncio.AbstractEventLoop]
76
- ) -> ThermocyclerDriver:
77
+ ) -> ThermocyclerDriver | ThermocyclerDriverV2:
77
78
  """
78
79
  Create a thermocycler driver.
79
80
 
@@ -148,10 +149,15 @@ class ThermocyclerDriverFactory:
148
149
  return response.startswith(GCODE.DEVICE_INFO)
149
150
 
150
151
 
151
- class ThermocyclerDriver(AbstractThermocyclerDriver):
152
+ _ConnectionKind = TypeVar(
153
+ "_ConnectionKind", SerialConnection, AsyncResponseSerialConnection
154
+ )
155
+
156
+
157
+ class _BaseThermocyclerDriver(AbstractThermocyclerDriver, Generic[_ConnectionKind]):
152
158
  def __init__(
153
159
  self,
154
- connection: SerialKind,
160
+ connection: _ConnectionKind,
155
161
  ) -> None:
156
162
  """
157
163
  Constructor
@@ -159,7 +165,7 @@ class ThermocyclerDriver(AbstractThermocyclerDriver):
159
165
  Args:
160
166
  connection: SerialConnection to the thermocycler
161
167
  """
162
- self._connection = connection
168
+ self._connection: _ConnectionKind = connection
163
169
 
164
170
  async def connect(self) -> None:
165
171
  """Connect to thermocycler"""
@@ -226,6 +232,7 @@ class ThermocyclerDriver(AbstractThermocyclerDriver):
226
232
  temp: float,
227
233
  hold_time: Optional[float] = None,
228
234
  volume: Optional[float] = None,
235
+ ramp_rate: Optional[float] = None,
229
236
  ) -> None:
230
237
  """Send set plate temperature command"""
231
238
  temp = min(BLOCK_TARGET_MAX, max(BLOCK_TARGET_MIN, temp))
@@ -328,8 +335,15 @@ class ThermocyclerDriver(AbstractThermocyclerDriver):
328
335
  "Gen1 Thermocyclers do not support the Jog Lid command."
329
336
  )
330
337
 
338
+ async def get_error_state(self) -> None:
339
+ """For the gen1, do nothing."""
340
+ pass
341
+
342
+
343
+ ThermocyclerDriver = _BaseThermocyclerDriver[SerialConnection]
331
344
 
332
- class ThermocyclerDriverV2(ThermocyclerDriver):
345
+
346
+ class ThermocyclerDriverV2(_BaseThermocyclerDriver[AsyncResponseSerialConnection]):
333
347
  """
334
348
  This driver is for Thermocycler model Gen2.
335
349
  """
@@ -343,10 +357,38 @@ class ThermocyclerDriverV2(ThermocyclerDriver):
343
357
  """
344
358
  super().__init__(connection)
345
359
 
346
- async def set_ramp_rate(self, ramp_rate: float) -> None:
347
- """Send a set ramp rate command"""
348
- # This command is fully unsupported on TC Gen2
349
- return None
360
+ async def set_plate_temperature(
361
+ self,
362
+ temp: float,
363
+ hold_time: Optional[float] = None,
364
+ volume: Optional[float] = None,
365
+ ramp_rate: Optional[float] = None,
366
+ ) -> None:
367
+ """Send set plate temperature command"""
368
+ temp = min(BLOCK_TARGET_MAX, max(BLOCK_TARGET_MIN, temp))
369
+
370
+ c = (
371
+ CommandBuilder(terminator=TC_COMMAND_TERMINATOR)
372
+ .add_gcode(gcode=GCODE.SET_PLATE_TEMP)
373
+ .add_float(
374
+ prefix="S", value=temp, precision=utils.TC_GCODE_ROUNDING_PRECISION
375
+ )
376
+ )
377
+ if hold_time is not None:
378
+ c = c.add_float(
379
+ prefix="H", value=hold_time, precision=utils.TC_GCODE_ROUNDING_PRECISION
380
+ )
381
+ if volume is not None:
382
+ c = c.add_float(
383
+ prefix="V", value=volume, precision=utils.TC_GCODE_ROUNDING_PRECISION
384
+ )
385
+
386
+ if ramp_rate is not None:
387
+ c = c.add_float(
388
+ prefix="R", value=ramp_rate, precision=utils.TC_GCODE_ROUNDING_PRECISION
389
+ )
390
+
391
+ await self._connection.send_command(command=c, retries=DEFAULT_COMMAND_RETRIES)
350
392
 
351
393
  async def get_device_info(self) -> Dict[str, str]:
352
394
  """Send get device info command"""
@@ -393,3 +435,12 @@ class ThermocyclerDriverV2(ThermocyclerDriver):
393
435
  .add_element("O")
394
436
  )
395
437
  await self._connection.send_command(command=c, retries=1)
438
+
439
+ async def get_error_state(self) -> None:
440
+ """Raise an error if the thermocycler is stuck in an error state."""
441
+ await self._connection.send_multiack_command(
442
+ command=CommandBuilder(terminator=TC_COMMAND_TERMINATOR).add_gcode(
443
+ gcode=GCODE.GET_ERROR_STATE
444
+ ),
445
+ acks=2,
446
+ )
@@ -67,10 +67,12 @@ class SimulatingDriver(AbstractThermocyclerDriver):
67
67
  temp: float,
68
68
  hold_time: Optional[float] = None,
69
69
  volume: Optional[float] = None,
70
+ ramp_rate: Optional[float] = None,
70
71
  ) -> None:
71
72
  self._plate_temperature.target = temp
72
73
  self._plate_temperature.current = temp
73
74
  self._plate_temperature.hold = 0
75
+ self._ramp_rate = ramp_rate
74
76
 
75
77
  @ensure_yield
76
78
  async def get_plate_temperature(self) -> PlateTemperature:
@@ -124,3 +126,7 @@ class SimulatingDriver(AbstractThermocyclerDriver):
124
126
  if angle < 0
125
127
  else ThermocyclerLidStatus.OPEN
126
128
  )
129
+
130
+ @ensure_yield
131
+ async def get_error_state(self) -> None:
132
+ return
@@ -44,6 +44,7 @@ from .execution_manager import ExecutionManagerProvider
44
44
  from .pause_manager import PauseManager
45
45
  from .module_control import AttachedModulesControl
46
46
  from .types import (
47
+ AsynchronousModuleErrorNotification,
47
48
  Axis,
48
49
  CriticalPoint,
49
50
  DoorState,
@@ -51,6 +52,7 @@ from .types import (
51
52
  ErrorMessageNotification,
52
53
  HardwareEventHandler,
53
54
  HardwareAction,
55
+ HardwareEvent,
54
56
  MotionChecks,
55
57
  PauseType,
56
58
  StatusBarState,
@@ -167,6 +169,18 @@ class API(
167
169
  except Exception:
168
170
  mod_log.exception("Errored during door state event callback")
169
171
 
172
+ def _send_module_notification(self, event: HardwareEvent) -> None:
173
+ if not isinstance(event, AsynchronousModuleErrorNotification):
174
+ return
175
+ mod_log.info(
176
+ f"Forwarding module event {event.event} for {event.module_model} {event.module_serial} at {event.port}"
177
+ )
178
+ for cb in self._callbacks:
179
+ try:
180
+ cb(event)
181
+ except Exception:
182
+ mod_log.exception("Errored during module asynchronous callback")
183
+
170
184
  def _reset_last_mount(self) -> None:
171
185
  self._last_moved_mount = None
172
186
 
@@ -247,7 +261,9 @@ class API(
247
261
  )
248
262
  await api_instance.cache_instruments()
249
263
  module_controls = await AttachedModulesControl.build(
250
- api_instance, board_revision=backend.board_revision
264
+ api_instance,
265
+ board_revision=backend.board_revision,
266
+ event_callback=api_instance._send_module_notification,
251
267
  )
252
268
  backend.module_controls = module_controls
253
269
  checked_loop.create_task(backend.watch(loop=checked_loop))
@@ -306,7 +322,9 @@ class API(
306
322
  )
307
323
  await api_instance.cache_instruments()
308
324
  module_controls = await AttachedModulesControl.build(
309
- api_instance, board_revision=backend.board_revision
325
+ api_instance,
326
+ board_revision=backend.board_revision,
327
+ event_callback=api_instance._send_module_notification,
310
328
  )
311
329
  backend.module_controls = module_controls
312
330
  await backend.watch()
@@ -1312,9 +1330,10 @@ class API(
1312
1330
  self.is_simulator
1313
1331
  ), "Cannot build simulating module from non-simulating hardware control API"
1314
1332
 
1315
- return await self._backend.module_controls.build_module(
1316
- port="",
1317
- usb_port=USBPort(name="", port_number=1, port_group=PortGroup.MAIN),
1333
+ return await self._backend.module_controls.register_simulated_module(
1334
+ simulated_usb_port=USBPort(
1335
+ name="", port_number=1, port_group=PortGroup.MAIN
1336
+ ),
1318
1337
  type=modules.ModuleType.from_model(model),
1319
1338
  sim_model=model.value,
1320
1339
  )
@@ -360,13 +360,19 @@ class Controller:
360
360
  """Run a probe and return the new position dict"""
361
361
  return await self._smoothie_driver.probe_axis(axis, distance)
362
362
 
363
- async def clean_up(self) -> None:
363
+ async def clean_up(self) -> None: # noqa: C901
364
364
  try:
365
365
  loop = asyncio.get_event_loop()
366
366
  except RuntimeError:
367
367
  return
368
+ if hasattr(self, "_module_controls") and self._module_controls is not None:
369
+ await self._module_controls.clean_up()
368
370
  if hasattr(self, "_event_watcher"):
369
- if loop.is_running() and self._event_watcher:
371
+ if (
372
+ loop.is_running()
373
+ and self._event_watcher
374
+ and not self._event_watcher.closed
375
+ ):
370
376
  self._event_watcher.close()
371
377
  if hasattr(self, "gpio_chardev"):
372
378
  try:
@@ -1398,6 +1398,9 @@ class OT3Controller(FlexBackend):
1398
1398
  except RuntimeError:
1399
1399
  return
1400
1400
 
1401
+ if hasattr(self, "_module_controls") and self._module_controls is not None:
1402
+ await self._module_controls.clean_up()
1403
+
1401
1404
  if hasattr(self, "_event_watcher"):
1402
1405
  if (
1403
1406
  loop.is_running()
@@ -728,7 +728,8 @@ class OT3Simulator(FlexBackend):
728
728
  @ensure_yield
729
729
  async def clean_up(self) -> None:
730
730
  """Clean up."""
731
- pass
731
+ if hasattr(self, "_module_controls") and self._module_controls is not None:
732
+ await self._module_controls.clean_up()
732
733
 
733
734
  @staticmethod
734
735
  def _get_home_position() -> Dict[Axis, float]:
@@ -414,7 +414,8 @@ class Simulator:
414
414
 
415
415
  @ensure_yield
416
416
  async def clean_up(self) -> None:
417
- pass
417
+ if hasattr(self, "_module_controls") and self._module_controls is not None:
418
+ await self._module_controls.clean_up()
418
419
 
419
420
  @ensure_yield
420
421
  async def configure_mount(
@@ -91,6 +91,8 @@ class SubsystemManager:
91
91
  self._present_tools = tools.types.ToolSummary(
92
92
  left=None, right=None, gripper=None
93
93
  )
94
+ # This is intended to be an internal variable but is modified in unit tests to avoid long timeouts
95
+ self._check_device_update_timeout = 10.0
94
96
 
95
97
  @property
96
98
  def ok(self) -> bool:
@@ -183,11 +185,12 @@ class SubsystemManager:
183
185
  return self._tool_task_state is True
184
186
 
185
187
  async def _check_devices_after_update(
186
- self, devices: Set[SubSystem], timeout_sec: float = 10.0
188
+ self, devices: Set[SubSystem], timeout_sec: Optional[float] = None
187
189
  ) -> None:
188
190
  try:
189
191
  await asyncio.wait_for(
190
- self._do_check_devices_after_update(devices), timeout=timeout_sec
192
+ self._do_check_devices_after_update(devices),
193
+ timeout=timeout_sec or self._check_device_update_timeout,
191
194
  )
192
195
  except asyncio.TimeoutError:
193
196
  raise RuntimeError("Device failed to come back after firmware update")
@@ -10,12 +10,14 @@ class AbstractEmulator(ABC):
10
10
  """Handle a command and return a response."""
11
11
  ...
12
12
 
13
- @staticmethod
14
- def get_terminator() -> bytes:
13
+ def get_terminator(self) -> bytes:
15
14
  """Get the command terminator for messages coming from PI."""
16
15
  return b"\r\n\r\n"
17
16
 
18
- @staticmethod
19
- def get_ack() -> bytes:
17
+ def get_ack(self) -> bytes:
20
18
  """Get the command ack send to the PI."""
21
19
  return b"ok\r\nok\r\n"
20
+
21
+ def get_autoack(self) -> bool:
22
+ """Should this system automatically acknowledge messages?"""
23
+ return True