opentrons 8.7.0a7__py3-none-any.whl → 8.8.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 (109) hide show
  1. opentrons/_version.py +2 -2
  2. opentrons/cli/analyze.py +4 -1
  3. opentrons/config/__init__.py +7 -0
  4. opentrons/drivers/asyncio/communication/serial_connection.py +8 -5
  5. opentrons/drivers/flex_stacker/driver.py +6 -1
  6. opentrons/drivers/vacuum_module/__init__.py +5 -0
  7. opentrons/drivers/vacuum_module/abstract.py +93 -0
  8. opentrons/drivers/vacuum_module/driver.py +208 -0
  9. opentrons/drivers/vacuum_module/errors.py +39 -0
  10. opentrons/drivers/vacuum_module/simulator.py +85 -0
  11. opentrons/drivers/vacuum_module/types.py +79 -0
  12. opentrons/execute.py +3 -0
  13. opentrons/hardware_control/backends/flex_protocol.py +2 -0
  14. opentrons/hardware_control/backends/ot3controller.py +35 -2
  15. opentrons/hardware_control/backends/ot3simulator.py +2 -0
  16. opentrons/hardware_control/backends/ot3utils.py +37 -0
  17. opentrons/hardware_control/module_control.py +23 -2
  18. opentrons/hardware_control/modules/mod_abc.py +1 -1
  19. opentrons/hardware_control/modules/types.py +1 -1
  20. opentrons/hardware_control/motion_utilities.py +6 -6
  21. opentrons/hardware_control/ot3api.py +62 -13
  22. opentrons/hardware_control/protocols/gripper_controller.py +1 -0
  23. opentrons/hardware_control/protocols/liquid_handler.py +6 -2
  24. opentrons/hardware_control/types.py +12 -0
  25. opentrons/legacy_commands/commands.py +58 -5
  26. opentrons/legacy_commands/module_commands.py +29 -0
  27. opentrons/legacy_commands/protocol_commands.py +33 -1
  28. opentrons/legacy_commands/types.py +75 -1
  29. opentrons/protocol_api/_transfer_liquid_validation.py +17 -2
  30. opentrons/protocol_api/_types.py +2 -0
  31. opentrons/protocol_api/core/engine/_default_labware_versions.py +1 -0
  32. opentrons/protocol_api/core/engine/deck_conflict.py +3 -1
  33. opentrons/protocol_api/core/engine/instrument.py +109 -26
  34. opentrons/protocol_api/core/engine/module_core.py +27 -3
  35. opentrons/protocol_api/core/engine/protocol.py +33 -1
  36. opentrons/protocol_api/core/engine/stringify.py +2 -0
  37. opentrons/protocol_api/core/instrument.py +19 -2
  38. opentrons/protocol_api/core/legacy/legacy_instrument_core.py +19 -2
  39. opentrons/protocol_api/core/legacy/legacy_module_core.py +15 -4
  40. opentrons/protocol_api/core/legacy/legacy_protocol_core.py +12 -0
  41. opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py +19 -2
  42. opentrons/protocol_api/core/module.py +25 -2
  43. opentrons/protocol_api/core/protocol.py +12 -0
  44. opentrons/protocol_api/instrument_context.py +388 -2
  45. opentrons/protocol_api/labware.py +5 -2
  46. opentrons/protocol_api/module_contexts.py +133 -30
  47. opentrons/protocol_api/protocol_context.py +61 -17
  48. opentrons/protocol_api/robot_context.py +3 -4
  49. opentrons/protocol_api/validation.py +43 -2
  50. opentrons/protocol_engine/__init__.py +4 -0
  51. opentrons/protocol_engine/actions/__init__.py +2 -0
  52. opentrons/protocol_engine/actions/actions.py +9 -0
  53. opentrons/protocol_engine/commands/__init__.py +14 -0
  54. opentrons/protocol_engine/commands/absorbance_reader/read.py +22 -23
  55. opentrons/protocol_engine/commands/aspirate_while_tracking.py +52 -19
  56. opentrons/protocol_engine/commands/capture_image.py +302 -0
  57. opentrons/protocol_engine/commands/command.py +1 -0
  58. opentrons/protocol_engine/commands/command_unions.py +13 -0
  59. opentrons/protocol_engine/commands/dispense_while_tracking.py +56 -19
  60. opentrons/protocol_engine/commands/flex_stacker/common.py +35 -0
  61. opentrons/protocol_engine/commands/flex_stacker/set_stored_labware.py +7 -0
  62. opentrons/protocol_engine/commands/heater_shaker/set_shake_speed.py +1 -1
  63. opentrons/protocol_engine/commands/heater_shaker/set_target_temperature.py +1 -1
  64. opentrons/protocol_engine/commands/move_labware.py +3 -4
  65. opentrons/protocol_engine/commands/move_to_addressable_area_for_drop_tip.py +1 -1
  66. opentrons/protocol_engine/commands/movement_common.py +29 -2
  67. opentrons/protocol_engine/commands/pipetting_common.py +48 -3
  68. opentrons/protocol_engine/commands/thermocycler/set_target_block_temperature.py +12 -9
  69. opentrons/protocol_engine/commands/thermocycler/set_target_lid_temperature.py +17 -12
  70. opentrons/protocol_engine/commands/thermocycler/start_run_extended_profile.py +1 -1
  71. opentrons/protocol_engine/create_protocol_engine.py +12 -0
  72. opentrons/protocol_engine/engine_support.py +3 -0
  73. opentrons/protocol_engine/errors/__init__.py +8 -0
  74. opentrons/protocol_engine/errors/exceptions.py +64 -0
  75. opentrons/protocol_engine/execution/__init__.py +2 -0
  76. opentrons/protocol_engine/execution/command_executor.py +54 -1
  77. opentrons/protocol_engine/execution/create_queue_worker.py +4 -1
  78. opentrons/protocol_engine/execution/labware_movement.py +13 -4
  79. opentrons/protocol_engine/execution/pipetting.py +19 -25
  80. opentrons/protocol_engine/protocol_engine.py +62 -2
  81. opentrons/protocol_engine/resources/__init__.py +2 -0
  82. opentrons/protocol_engine/resources/camera_provider.py +110 -0
  83. opentrons/protocol_engine/resources/file_provider.py +133 -58
  84. opentrons/protocol_engine/slot_standardization.py +2 -0
  85. opentrons/protocol_engine/state/camera.py +54 -0
  86. opentrons/protocol_engine/state/commands.py +24 -4
  87. opentrons/protocol_engine/state/geometry.py +68 -10
  88. opentrons/protocol_engine/state/labware.py +10 -6
  89. opentrons/protocol_engine/state/labware_origin_math/stackup_origin_to_labware_origin.py +6 -1
  90. opentrons/protocol_engine/state/modules.py +9 -0
  91. opentrons/protocol_engine/state/preconditions.py +59 -0
  92. opentrons/protocol_engine/state/state.py +30 -0
  93. opentrons/protocol_engine/state/state_summary.py +2 -0
  94. opentrons/protocol_engine/state/update_types.py +10 -0
  95. opentrons/protocol_engine/types/__init__.py +14 -1
  96. opentrons/protocol_engine/types/command_preconditions.py +18 -0
  97. opentrons/protocol_engine/types/location.py +26 -2
  98. opentrons/protocol_engine/types/module.py +1 -1
  99. opentrons/protocol_runner/protocol_runner.py +14 -1
  100. opentrons/protocol_runner/run_orchestrator.py +31 -0
  101. opentrons/protocols/advanced_control/transfers/transfer_liquid_utils.py +2 -2
  102. opentrons/simulate.py +3 -0
  103. opentrons/system/camera.py +333 -3
  104. opentrons/system/ffmpeg.py +110 -0
  105. {opentrons-8.7.0a7.dist-info → opentrons-8.8.0a7.dist-info}/METADATA +4 -4
  106. {opentrons-8.7.0a7.dist-info → opentrons-8.8.0a7.dist-info}/RECORD +109 -97
  107. {opentrons-8.7.0a7.dist-info → opentrons-8.8.0a7.dist-info}/WHEEL +0 -0
  108. {opentrons-8.7.0a7.dist-info → opentrons-8.8.0a7.dist-info}/entry_points.txt +0 -0
  109. {opentrons-8.7.0a7.dist-info → opentrons-8.8.0a7.dist-info}/licenses/LICENSE +0 -0
@@ -10,12 +10,15 @@ from opentrons_shared_data.errors.exceptions import (
10
10
  EnumeratedError,
11
11
  PythonException,
12
12
  )
13
+ from opentrons_shared_data.data_files import MimeType
13
14
 
14
15
  from opentrons.protocol_engine.commands.command import SuccessData
15
16
  from opentrons.protocol_engine.notes import make_error_recovery_debug_note
16
17
 
17
18
  from ..state.state import StateStore
18
- from ..resources import ModelUtils, FileProvider
19
+ from ..resources import ModelUtils, FileProvider, CameraProvider
20
+ from ..resources.file_provider import ImageCaptureCmdFileNameMetadata
21
+ from ..resources.camera_provider import ImageParameters
19
22
  from ..commands import CommandStatus
20
23
  from ..actions import (
21
24
  ActionDispatcher,
@@ -36,6 +39,7 @@ from .run_control import RunControlHandler
36
39
  from .rail_lights import RailLightsHandler
37
40
  from .status_bar import StatusBarHandler
38
41
  from .task_handler import TaskHandler
42
+ from ..commands import Command
39
43
 
40
44
 
41
45
  log = getLogger(__name__)
@@ -75,6 +79,7 @@ class CommandExecutor:
75
79
  self,
76
80
  hardware_api: HardwareControlAPI,
77
81
  file_provider: FileProvider,
82
+ camera_provider: CameraProvider,
78
83
  state_store: StateStore,
79
84
  action_dispatcher: ActionDispatcher,
80
85
  equipment: EquipmentHandler,
@@ -93,6 +98,7 @@ class CommandExecutor:
93
98
  """Initialize the CommandExecutor with access to its dependencies."""
94
99
  self._hardware_api = hardware_api
95
100
  self._file_provider = file_provider
101
+ self._camera_provider = camera_provider
96
102
  self._state_store = state_store
97
103
  self._action_dispatcher = action_dispatcher
98
104
  self._equipment = equipment
@@ -123,6 +129,7 @@ class CommandExecutor:
123
129
  state_view=self._state_store,
124
130
  hardware_api=self._hardware_api,
125
131
  file_provider=self._file_provider,
132
+ camera_provider=self._camera_provider,
126
133
  equipment=self._equipment,
127
134
  movement=self._movement,
128
135
  gantry_mover=self._gantry_mover,
@@ -148,6 +155,7 @@ class CommandExecutor:
148
155
  log.debug(
149
156
  f"Executing {running_command.id}, {running_command.commandType}, {running_command.params}"
150
157
  )
158
+ error_occurred = False
151
159
  try:
152
160
  result = await command_impl.execute(
153
161
  running_command.params # type: ignore[arg-type]
@@ -171,6 +179,7 @@ class CommandExecutor:
171
179
  running_command,
172
180
  None,
173
181
  )
182
+
174
183
  note_tracker(make_error_recovery_debug_note(error_recovery_type))
175
184
  self._action_dispatcher.dispatch(
176
185
  FailCommandAction(
@@ -183,6 +192,7 @@ class CommandExecutor:
183
192
  type=error_recovery_type,
184
193
  )
185
194
  )
195
+ error_occurred = True
186
196
 
187
197
  else:
188
198
  if isinstance(result, SuccessData):
@@ -218,7 +228,50 @@ class CommandExecutor:
218
228
  type=error_recovery_type,
219
229
  )
220
230
  )
231
+ error_occurred = True
232
+ finally:
233
+ # Handle error image capture if appropriate
234
+ if error_occurred:
235
+ await self.capture_error_image(running_command)
221
236
 
222
237
  def cancel_tasks(self, message: str | None = None) -> None:
223
238
  """Cancel all concurrent tasks."""
224
239
  self._task_handler.cancel_all(message=message)
240
+
241
+ async def capture_error_image(self, running_command: Command) -> None:
242
+ """Capture an image of an error event."""
243
+ try:
244
+ camera_enablement = self._state_store.camera.get_enablement_settings()
245
+ if camera_enablement is None:
246
+ # Utilize the global camera settings
247
+ camera_enablement = await self._camera_provider.get_camera_settings()
248
+ # Only capture photos of errors if the setting to do so is enabled
249
+ if (
250
+ camera_enablement.cameraEnabled
251
+ and camera_enablement.errorRecoveryEnabled
252
+ ):
253
+ # todo(chb, 2025-10-25): Eventually we will need to pass in client provided global settings here
254
+ image_data = await self._camera_provider.capture_image(
255
+ self._state_store.config.robot_type, ImageParameters()
256
+ )
257
+ commands = self._state_store.commands.get_all()
258
+ prev_command_id = commands[-2].id if len(commands) > 1 else ""
259
+ if image_data:
260
+ write_result = await self._file_provider.write_file(
261
+ data=image_data,
262
+ mime_type=MimeType.IMAGE_JPEG,
263
+ command_metadata=ImageCaptureCmdFileNameMetadata(
264
+ command_id=running_command.id,
265
+ prev_command_id=prev_command_id,
266
+ step_number=len(commands),
267
+ base_filename=None,
268
+ command_timestamp=running_command.createdAt,
269
+ ),
270
+ )
271
+ log.info(
272
+ f"Image captured of error event with file name: {write_result.name}"
273
+ )
274
+ except Exception as e:
275
+ log.info(
276
+ f"Failed to capture image of error with the following exception: {e}"
277
+ )
@@ -6,7 +6,7 @@ from opentrons.protocol_engine.execution.rail_lights import RailLightsHandler
6
6
 
7
7
  from ..state.state import StateStore
8
8
  from ..actions import ActionDispatcher
9
- from ..resources import FileProvider
9
+ from ..resources import FileProvider, CameraProvider
10
10
  from .equipment import EquipmentHandler
11
11
  from .movement import MovementHandler
12
12
  from .gantry_mover import create_gantry_mover
@@ -23,6 +23,7 @@ from .task_handler import TaskHandler
23
23
  def create_queue_worker(
24
24
  hardware_api: HardwareControlAPI,
25
25
  file_provider: FileProvider,
26
+ camera_provider: CameraProvider,
26
27
  state_store: StateStore,
27
28
  action_dispatcher: ActionDispatcher,
28
29
  command_generator: Callable[[], AsyncGenerator[str, None]],
@@ -32,6 +33,7 @@ def create_queue_worker(
32
33
  Arguments:
33
34
  hardware_api: Hardware control API to pass down to dependencies.
34
35
  file_provider: Provides access to robot server file writing procedures for protocol output.
36
+ camera_provider: Provides access to camera interface with image capture and callbacks.
35
37
  state_store: StateStore to pass down to dependencies.
36
38
  action_dispatcher: ActionDispatcher to pass down to dependencies.
37
39
  error_recovery_policy: ErrorRecoveryPolicy to pass down to dependencies.
@@ -85,6 +87,7 @@ def create_queue_worker(
85
87
  command_executor = CommandExecutor(
86
88
  hardware_api=hardware_api,
87
89
  file_provider=file_provider,
90
+ camera_provider=camera_provider,
88
91
  state_store=state_store,
89
92
  action_dispatcher=action_dispatcher,
90
93
  equipment=equipment_handler,
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  from typing import Optional, TYPE_CHECKING, overload
6
6
 
7
- from opentrons_shared_data.labware.labware_definition import LabwareDefinition
7
+ from opentrons_shared_data.labware.labware_definition import LabwareDefinition, Quirks
8
8
 
9
9
  from opentrons.types import Point
10
10
 
@@ -32,6 +32,7 @@ from ..types import (
32
32
  LabwareLocation,
33
33
  OnDeckLabwareLocation,
34
34
  GripperMoveType,
35
+ AccessibleByGripperLocation,
35
36
  )
36
37
 
37
38
  if TYPE_CHECKING:
@@ -95,7 +96,7 @@ class LabwareMovementHandler:
95
96
  *,
96
97
  labware_id: str,
97
98
  current_location: OnDeckLabwareLocation,
98
- new_location: OnDeckLabwareLocation,
99
+ new_location: AccessibleByGripperLocation,
99
100
  user_pick_up_offset: Point,
100
101
  user_drop_offset: Point,
101
102
  post_drop_slide_offset: Optional[Point],
@@ -108,7 +109,7 @@ class LabwareMovementHandler:
108
109
  *,
109
110
  labware_definition: LabwareDefinition,
110
111
  current_location: OnDeckLabwareLocation,
111
- new_location: OnDeckLabwareLocation,
112
+ new_location: AccessibleByGripperLocation,
112
113
  user_pick_up_offset: Point,
113
114
  user_drop_offset: Point,
114
115
  post_drop_slide_offset: Optional[Point],
@@ -122,7 +123,7 @@ class LabwareMovementHandler:
122
123
  labware_id: str | None = None,
123
124
  labware_definition: LabwareDefinition | None = None,
124
125
  current_location: OnDeckLabwareLocation,
125
- new_location: OnDeckLabwareLocation,
126
+ new_location: AccessibleByGripperLocation,
126
127
  user_pick_up_offset: Point,
127
128
  user_drop_offset: Point,
128
129
  post_drop_slide_offset: Optional[Point],
@@ -235,6 +236,13 @@ class LabwareMovementHandler:
235
236
  labware_definition=labware_definition
236
237
  )
237
238
 
239
+ disable_geometry_grip_check = False
240
+ if labware_definition.parameters.quirks is not None:
241
+ disable_geometry_grip_check = (
242
+ Quirks.disableGeometryBasedGripCheck.value
243
+ in labware_definition.parameters.quirks
244
+ )
245
+
238
246
  # todo(mm, 2024-09-26): This currently raises a lower-level 2015 FailedGripperPickupError.
239
247
  # Convert this to a higher-level 3001 LabwareDroppedError or 3002 LabwareNotPickedUpError,
240
248
  # depending on what waypoint we're at, to propagate a more specific error code to users.
@@ -242,6 +250,7 @@ class LabwareMovementHandler:
242
250
  expected_grip_width=grip_specs.targetY,
243
251
  grip_width_uncertainty_wider=grip_specs.uncertaintyWider,
244
252
  grip_width_uncertainty_narrower=grip_specs.uncertaintyNarrower,
253
+ disable_geometry_grip_check=disable_geometry_grip_check,
245
254
  )
246
255
  await ot3api.move_to(
247
256
  mount=gripper_mount, abs_position=waypoint_data.position
@@ -13,13 +13,13 @@ from ..errors.exceptions import (
13
13
  InvalidAspirateVolumeError,
14
14
  InvalidPushOutVolumeError,
15
15
  InvalidDispenseVolumeError,
16
- InvalidLiquidHeightFound,
17
16
  )
18
17
  from opentrons.protocol_engine.types import WellLocation
19
18
  from opentrons.protocol_engine.types.liquid_level_detection import (
20
19
  SimulatedProbeResult,
21
20
  LiquidTrackingType,
22
21
  )
22
+ from opentrons.types import Point
23
23
 
24
24
  # 1e-9 µL (1 femtoliter!) is a good value because:
25
25
  # * It's large relative to rounding errors that occur in practice in protocols. For
@@ -61,7 +61,9 @@ class PipettingHandler(TypingProtocol):
61
61
  well_name: str,
62
62
  volume: float,
63
63
  flow_rate: float,
64
+ end_point: Point,
64
65
  command_note_adder: CommandNoteAdder,
66
+ movement_delay: Optional[float] = None,
65
67
  ) -> float:
66
68
  """Set flow-rate and aspirate while tracking."""
67
69
 
@@ -72,8 +74,10 @@ class PipettingHandler(TypingProtocol):
72
74
  well_name: str,
73
75
  volume: float,
74
76
  flow_rate: float,
77
+ end_point: Point,
75
78
  push_out: Optional[float],
76
79
  is_full_dispense: bool = False,
80
+ movement_delay: Optional[float] = None,
77
81
  ) -> float:
78
82
  """Set flow-rate and dispense while tracking."""
79
83
 
@@ -184,7 +188,9 @@ class HardwarePipettingHandler(PipettingHandler):
184
188
  well_name: str,
185
189
  volume: float,
186
190
  flow_rate: float,
191
+ end_point: Point,
187
192
  command_note_adder: CommandNoteAdder,
193
+ movement_delay: Optional[float] = None,
188
194
  ) -> float:
189
195
  """Set flow-rate and aspirate.
190
196
 
@@ -195,22 +201,13 @@ class HardwarePipettingHandler(PipettingHandler):
195
201
  hw_pipette, adjusted_volume = self.get_hw_aspirate_params(
196
202
  pipette_id, volume, command_note_adder
197
203
  )
198
- aspirate_z_distance = self._state_view.geometry.get_liquid_handling_z_change(
199
- labware_id=labware_id,
200
- well_name=well_name,
201
- operation_volume=volume * -1,
202
- pipette_id=pipette_id,
203
- )
204
- if isinstance(aspirate_z_distance, SimulatedProbeResult):
205
- raise InvalidLiquidHeightFound(
206
- "Aspirate distance must be a float in Hardware pipetting handler."
207
- )
204
+
208
205
  with self._set_flow_rate(pipette=hw_pipette, aspirate_flow_rate=flow_rate):
209
206
  await self._hardware_api.aspirate_while_tracking(
210
207
  mount=hw_pipette.mount,
211
- z_distance=aspirate_z_distance,
212
- flow_rate=flow_rate,
208
+ end_point=end_point,
213
209
  volume=adjusted_volume,
210
+ movement_delay=movement_delay,
214
211
  )
215
212
  return adjusted_volume
216
213
 
@@ -221,8 +218,10 @@ class HardwarePipettingHandler(PipettingHandler):
221
218
  well_name: str,
222
219
  volume: float,
223
220
  flow_rate: float,
221
+ end_point: Point,
224
222
  push_out: Optional[float],
225
223
  is_full_dispense: bool = False,
224
+ movement_delay: Optional[float] = None,
226
225
  ) -> float:
227
226
  """Set flow-rate and dispense.
228
227
 
@@ -231,24 +230,15 @@ class HardwarePipettingHandler(PipettingHandler):
231
230
  """
232
231
  # get mount and config data from state and hardware controller
233
232
  hw_pipette, adjusted_volume = self.get_hw_dispense_params(pipette_id, volume)
234
- dispense_z_distance = self._state_view.geometry.get_liquid_handling_z_change(
235
- labware_id=labware_id,
236
- well_name=well_name,
237
- operation_volume=volume,
238
- pipette_id=pipette_id,
239
- )
240
- if isinstance(dispense_z_distance, SimulatedProbeResult):
241
- raise InvalidLiquidHeightFound(
242
- "Dispense distance must be a float in Hardware pipetting handler."
243
- )
233
+
244
234
  with self._set_flow_rate(pipette=hw_pipette, dispense_flow_rate=flow_rate):
245
235
  await self._hardware_api.dispense_while_tracking(
246
236
  mount=hw_pipette.mount,
247
- z_distance=dispense_z_distance,
248
- flow_rate=flow_rate,
237
+ end_point=end_point,
249
238
  volume=adjusted_volume,
250
239
  push_out=push_out,
251
240
  is_full_dispense=is_full_dispense,
241
+ movement_delay=movement_delay,
252
242
  )
253
243
  return adjusted_volume
254
244
 
@@ -476,7 +466,9 @@ class VirtualPipettingHandler(PipettingHandler):
476
466
  well_name: str,
477
467
  volume: float,
478
468
  flow_rate: float,
469
+ end_point: Point,
479
470
  command_note_adder: CommandNoteAdder,
471
+ movement_delay: Optional[float] = None,
480
472
  ) -> float:
481
473
  """Virtually aspirate (no-op)."""
482
474
  self._validate_tip_attached(pipette_id=pipette_id, command_name="aspirate")
@@ -495,8 +487,10 @@ class VirtualPipettingHandler(PipettingHandler):
495
487
  well_name: str,
496
488
  volume: float,
497
489
  flow_rate: float,
490
+ end_point: Point,
498
491
  push_out: Optional[float],
499
492
  is_full_dispense: bool = False,
493
+ movement_delay: Optional[float] = None,
500
494
  ) -> float:
501
495
  """Virtually dispense (no-op)."""
502
496
  # TODO (tz, 8-23-23): add a check for push_out not larger that the max volume allowed when working on this https://opentrons.atlassian.net/browse/RSS-329
@@ -13,6 +13,7 @@ from opentrons_shared_data.labware.labware_definition import LabwareDefinition
13
13
  from opentrons.hardware_control import HardwareControlAPI
14
14
  from opentrons.hardware_control.modules import AbstractModule as HardwareModuleAPI
15
15
  from opentrons.hardware_control.types import PauseType as HardwarePauseType
16
+ from opentrons.system import camera
16
17
 
17
18
  from .actions.actions import (
18
19
  ResumeFromRecoveryAction,
@@ -22,7 +23,8 @@ from .errors import ProtocolCommandFailedError, ErrorOccurrence, CommandNotAllow
22
23
  from .errors.exceptions import EStopActivatedError
23
24
  from .error_recovery_policy import ErrorRecoveryPolicy
24
25
  from . import commands, slot_standardization, labware_offset_standardization
25
- from .resources import ModelUtils, ModuleDataProvider, FileProvider
26
+ from .resources import ModelUtils, ModuleDataProvider, FileProvider, CameraProvider
27
+ from .resources.camera_provider import CameraSettings
26
28
  from .types import (
27
29
  LabwareOffset,
28
30
  LabwareOffsetCreate,
@@ -55,6 +57,7 @@ from .actions import (
55
57
  AddLabwareOffsetAction,
56
58
  AddLabwareDefinitionAction,
57
59
  AddLiquidAction,
60
+ AddCameraSettingsAction,
58
61
  SetDeckConfigurationAction,
59
62
  AddAddressableAreaAction,
60
63
  AddModuleAction,
@@ -96,6 +99,7 @@ class ProtocolEngine:
96
99
  door_watcher: DoorWatcher,
97
100
  module_data_provider: ModuleDataProvider,
98
101
  file_provider: FileProvider,
102
+ camera_provider: CameraProvider,
99
103
  queue_worker: Optional[QueueWorker] = None,
100
104
  ) -> None:
101
105
  """Initialize a ProtocolEngine instance.
@@ -107,6 +111,7 @@ class ProtocolEngine:
107
111
  """
108
112
  self._hardware_api = hardware_api
109
113
  self._file_provider = file_provider
114
+ self._camera_provider = camera_provider
110
115
  self._state_store = state_store
111
116
  self._model_utils = model_utils
112
117
  self._action_dispatcher = action_dispatcher
@@ -405,6 +410,39 @@ class ProtocolEngine:
405
410
  await self._do_hardware_stop()
406
411
  return True
407
412
 
413
+ async def module_disconnected(
414
+ self, module_model: ModuleModel, serial: str | None
415
+ ) -> bool:
416
+ """Signal to the engine that a module has disconnected.
417
+
418
+ The return value of this function signals whether the module was relevant to this
419
+ protocol or not. If the function returns True, the module was relevant. The engine
420
+ will stop, and the caller should call `finish()` with an appropriate error to indicate
421
+ the missing module. If the function returns False, the error is not relevant. The engine
422
+ will not stop, and the caller should not call `finish()`.
423
+
424
+ Module disconnects are signaled when a hardware module's status poller indicates that the
425
+ module is no logner connected - for instance, someone unplugs the module, or it crashes.
426
+ These errors are not related to a particular command, even a currently-happening module
427
+ control command for the module in the error state.
428
+
429
+ Similar to an estop error, the error can occur at any time relative to the lifecycle
430
+ of the engine run or of any particular command.
431
+
432
+ Unlike an estop, the motion control hardware will not be raising an error and will not
433
+ stop on its own; the stop action derived from this call will do that.
434
+ """
435
+ if not self._state_store.modules.get_has_module_probably_matching_hardware_details(
436
+ module_model, serial
437
+ ):
438
+ return False
439
+ self._stop_from_asynchronous_error()
440
+ # like self.request_stop, and unlike self.estop(), we must explicitly request that the
441
+ # hardware stops execution, since not all asynchronous errors will cause the hardware
442
+ # to know that it should stop.
443
+ await self._do_hardware_stop()
444
+ return True
445
+
408
446
  async def _do_hardware_stop(self) -> None:
409
447
  """Make the hardware stop now."""
410
448
  if self._hardware_api.is_movement_execution_taskified():
@@ -445,7 +483,7 @@ class ProtocolEngine:
445
483
  )
446
484
  self._state_store.commands.raise_fatal_command_error()
447
485
 
448
- async def finish(
486
+ async def finish( # noqa: C901
449
487
  self,
450
488
  error: Optional[Exception] = None,
451
489
  drop_tips_after_run: bool = True,
@@ -559,6 +597,16 @@ class ProtocolEngine:
559
597
  else:
560
598
  finish_error_details = None
561
599
 
600
+ try:
601
+ await camera.update_live_stream_status(
602
+ self.state_view.config.robot_type,
603
+ False,
604
+ self._camera_provider,
605
+ self.state_view.camera.get_enablement_settings(),
606
+ )
607
+ except Exception as e:
608
+ _log.exception(f"Exception during live stream post-run cleanup: {e}")
609
+
562
610
  self._action_dispatcher.dispatch(
563
611
  HardwareStoppedAction(
564
612
  completed_at=self._model_utils.get_timestamp(),
@@ -596,6 +644,17 @@ class ProtocolEngine:
596
644
  labware_offset_id=labware_offset_id
597
645
  )
598
646
 
647
+ def add_camera_enablement_settings(
648
+ self, enablement_settings: CameraSettings
649
+ ) -> CameraSettings:
650
+ """Add new camera enablement settings."""
651
+ self._action_dispatcher.dispatch(
652
+ AddCameraSettingsAction(enablement_settings=enablement_settings)
653
+ )
654
+ camera_settings = self.state_view.camera.get_enablement_settings()
655
+ assert camera_settings is not None
656
+ return camera_settings
657
+
599
658
  def add_labware_definition(self, definition: LabwareDefinition) -> LabwareUri:
600
659
  """Add a labware definition to the state for subsequent labware loads."""
601
660
  self._action_dispatcher.dispatch(
@@ -676,6 +735,7 @@ class ProtocolEngine:
676
735
  self._queue_worker = create_queue_worker(
677
736
  hardware_api=self._hardware_api,
678
737
  file_provider=self._file_provider,
738
+ camera_provider=self._camera_provider,
679
739
  state_store=self._state_store,
680
740
  action_dispatcher=self._action_dispatcher,
681
741
  command_generator=command_generator,
@@ -10,6 +10,7 @@ from .deck_data_provider import DeckDataProvider, DeckFixedLabware
10
10
  from .labware_data_provider import LabwareDataProvider
11
11
  from .module_data_provider import ModuleDataProvider
12
12
  from .file_provider import FileProvider
13
+ from .camera_provider import CameraProvider
13
14
  from .ot3_validation import ensure_ot3_hardware
14
15
  from .concurrency_provider import ConcurrencyProvider
15
16
 
@@ -22,6 +23,7 @@ __all__ = [
22
23
  "ConcurrencyProvider",
23
24
  "ModuleDataProvider",
24
25
  "FileProvider",
26
+ "CameraProvider",
25
27
  "ensure_ot3_hardware",
26
28
  "pipette_data_provider",
27
29
  "labware_validation",
@@ -0,0 +1,110 @@
1
+ """Camera interaction resource provider."""
2
+ from typing import Optional, Callable, Tuple, Awaitable
3
+ from pydantic import BaseModel, Field
4
+ from ..errors import CameraCaptureError, CameraSettingsInvalidError
5
+ import logging
6
+
7
+ from opentrons_shared_data.robot.types import RobotType
8
+
9
+ log = logging.getLogger(__name__)
10
+
11
+
12
+ class CameraError(BaseModel):
13
+ """Generic base class for Camera errors that occur on entities handled through the Camera Provider."""
14
+
15
+ message: str = Field(..., description="Description of error content.")
16
+ code: str | None = Field(
17
+ ..., description="Return code, if any, that was paired with the error."
18
+ )
19
+
20
+
21
+ class CameraSettings(BaseModel):
22
+ """Camera API settings for general enablement and use."""
23
+
24
+ cameraEnabled: bool = Field(
25
+ ..., description="Enablement status for general camera use."
26
+ )
27
+ liveStreamEnabled: bool = Field(
28
+ ..., description="Enablement status for the Opentrons Live Stream service."
29
+ )
30
+ errorRecoveryEnabled: bool = Field(
31
+ ..., description="Enablement status for camera usage with Error Recovery."
32
+ )
33
+
34
+
35
+ class ImageParameters(BaseModel):
36
+ """Parameters for an Image Capture to determine filters. These are the inputs as expected by FFMPEG."""
37
+
38
+ resolution: Optional[Tuple[int, int]] = Field(
39
+ None,
40
+ description="Width by height resolution in pixels for the image to be captured with.",
41
+ )
42
+ zoom: Optional[float] = Field(
43
+ None,
44
+ description="Multiplier to use when cropping and scaling a captured image.",
45
+ )
46
+ pan: Optional[Tuple[int, int]] = Field(
47
+ None,
48
+ description="Position to pan to for a given zoom. Format is X and Y coordinates (in pixels) to the bottom left of a frame.",
49
+ )
50
+ contrast: Optional[float] = Field(
51
+ None, description="The contrast to use when processing an image."
52
+ )
53
+ brightness: Optional[int] = Field(
54
+ None, description="The brightness to use when processing an image."
55
+ )
56
+ saturation: Optional[float] = Field(
57
+ None, description="The saturation to use when processing an image."
58
+ )
59
+
60
+
61
+ class CameraProvider:
62
+ """Provider class to wrap camera interactions between the server and the engine."""
63
+
64
+ def __init__(
65
+ self,
66
+ camera_settings_callback: Optional[Callable[[], CameraSettings]] = None,
67
+ image_capture_callback: Optional[
68
+ Callable[[RobotType, ImageParameters], Awaitable[bytes | CameraError]]
69
+ ] = None,
70
+ ) -> None:
71
+ """Initialize the interface callbacks of the Camera Provider within the Protocol Engine.
72
+
73
+ Params:
74
+ camera_settings_callback: Callback to query the Camera Enablement settings from the Boolean Settings table.
75
+ image_capture_callback: Callback to process an image capture request and return a bytestream of image data in response.
76
+ """
77
+ self._camera_settings_callback = camera_settings_callback
78
+ self._image_capture_callback = image_capture_callback
79
+
80
+ async def get_camera_settings(self) -> CameraSettings:
81
+ """Query the Robot Server for the current Camera Enablement settings."""
82
+ if self._camera_settings_callback is not None:
83
+ return self._camera_settings_callback()
84
+ # If we are in analysis or simulation, return as if the camera is enabled
85
+ return CameraSettings(
86
+ cameraEnabled=True, liveStreamEnabled=True, errorRecoveryEnabled=True
87
+ )
88
+
89
+ async def capture_image(
90
+ self, robot_type: RobotType, parameters: ImageParameters
91
+ ) -> bytes | None:
92
+ """Process through the Camera Executor on robot server an image capture request with a given set of filters.
93
+
94
+ Returns a bytesteam of image data upon success. Raises an error if an error occurred during capture.
95
+ Conditionally returns None if an image capture callback does not exist (simulation).
96
+ """
97
+ if self._image_capture_callback is not None:
98
+ capture_result = await self._image_capture_callback(robot_type, parameters)
99
+ if not isinstance(capture_result, CameraError):
100
+ return capture_result
101
+ else:
102
+ if capture_result.code == "IMAGE_SETTINGS":
103
+ raise CameraSettingsInvalidError(message=capture_result.message)
104
+ elif capture_result.code is not None:
105
+ error_str = f"Camera capture has failed to return an image with return code {capture_result.code}: {capture_result.message}"
106
+ else:
107
+ error_str = f"Camera capture has failed with exception: {capture_result.message}"
108
+ raise CameraCaptureError(message=error_str)
109
+ # Return None if the image capture callback is unavailable (simulation)
110
+ return None