opentrons 8.7.0a9__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.
Files changed (189) 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 +126 -49
  5. opentrons/drivers/heater_shaker/abstract.py +5 -0
  6. opentrons/drivers/heater_shaker/driver.py +10 -0
  7. opentrons/drivers/heater_shaker/simulator.py +4 -0
  8. opentrons/drivers/thermocycler/abstract.py +6 -0
  9. opentrons/drivers/thermocycler/driver.py +61 -10
  10. opentrons/drivers/thermocycler/simulator.py +6 -0
  11. opentrons/drivers/vacuum_module/__init__.py +5 -0
  12. opentrons/drivers/vacuum_module/abstract.py +93 -0
  13. opentrons/drivers/vacuum_module/driver.py +208 -0
  14. opentrons/drivers/vacuum_module/errors.py +39 -0
  15. opentrons/drivers/vacuum_module/simulator.py +85 -0
  16. opentrons/drivers/vacuum_module/types.py +79 -0
  17. opentrons/execute.py +3 -0
  18. opentrons/hardware_control/api.py +24 -5
  19. opentrons/hardware_control/backends/controller.py +8 -2
  20. opentrons/hardware_control/backends/flex_protocol.py +1 -0
  21. opentrons/hardware_control/backends/ot3controller.py +35 -2
  22. opentrons/hardware_control/backends/ot3simulator.py +3 -1
  23. opentrons/hardware_control/backends/ot3utils.py +37 -0
  24. opentrons/hardware_control/backends/simulator.py +2 -1
  25. opentrons/hardware_control/backends/subsystem_manager.py +5 -2
  26. opentrons/hardware_control/emulation/abstract_emulator.py +6 -4
  27. opentrons/hardware_control/emulation/connection_handler.py +8 -5
  28. opentrons/hardware_control/emulation/heater_shaker.py +12 -3
  29. opentrons/hardware_control/emulation/settings.py +1 -1
  30. opentrons/hardware_control/emulation/thermocycler.py +67 -15
  31. opentrons/hardware_control/module_control.py +105 -10
  32. opentrons/hardware_control/modules/__init__.py +3 -0
  33. opentrons/hardware_control/modules/absorbance_reader.py +11 -4
  34. opentrons/hardware_control/modules/flex_stacker.py +38 -9
  35. opentrons/hardware_control/modules/heater_shaker.py +42 -5
  36. opentrons/hardware_control/modules/magdeck.py +8 -4
  37. opentrons/hardware_control/modules/mod_abc.py +14 -6
  38. opentrons/hardware_control/modules/tempdeck.py +25 -5
  39. opentrons/hardware_control/modules/thermocycler.py +68 -11
  40. opentrons/hardware_control/modules/types.py +20 -1
  41. opentrons/hardware_control/modules/utils.py +11 -4
  42. opentrons/hardware_control/motion_utilities.py +6 -6
  43. opentrons/hardware_control/nozzle_manager.py +3 -0
  44. opentrons/hardware_control/ot3api.py +85 -17
  45. opentrons/hardware_control/poller.py +22 -8
  46. opentrons/hardware_control/protocols/liquid_handler.py +6 -2
  47. opentrons/hardware_control/scripts/update_module_fw.py +5 -0
  48. opentrons/hardware_control/types.py +43 -2
  49. opentrons/legacy_commands/commands.py +58 -5
  50. opentrons/legacy_commands/module_commands.py +52 -0
  51. opentrons/legacy_commands/protocol_commands.py +53 -1
  52. opentrons/legacy_commands/types.py +155 -1
  53. opentrons/motion_planning/deck_conflict.py +17 -12
  54. opentrons/motion_planning/waypoints.py +15 -29
  55. opentrons/protocol_api/__init__.py +5 -1
  56. opentrons/protocol_api/_transfer_liquid_validation.py +17 -2
  57. opentrons/protocol_api/_types.py +8 -1
  58. opentrons/protocol_api/core/common.py +3 -1
  59. opentrons/protocol_api/core/engine/_default_labware_versions.py +33 -11
  60. opentrons/protocol_api/core/engine/deck_conflict.py +3 -1
  61. opentrons/protocol_api/core/engine/instrument.py +109 -26
  62. opentrons/protocol_api/core/engine/labware.py +8 -1
  63. opentrons/protocol_api/core/engine/module_core.py +95 -4
  64. opentrons/protocol_api/core/engine/protocol.py +51 -2
  65. opentrons/protocol_api/core/engine/stringify.py +2 -0
  66. opentrons/protocol_api/core/engine/tasks.py +48 -0
  67. opentrons/protocol_api/core/engine/well.py +8 -0
  68. opentrons/protocol_api/core/instrument.py +19 -2
  69. opentrons/protocol_api/core/legacy/legacy_instrument_core.py +19 -2
  70. opentrons/protocol_api/core/legacy/legacy_module_core.py +33 -2
  71. opentrons/protocol_api/core/legacy/legacy_protocol_core.py +23 -1
  72. opentrons/protocol_api/core/legacy/legacy_well_core.py +4 -0
  73. opentrons/protocol_api/core/legacy/tasks.py +19 -0
  74. opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py +19 -2
  75. opentrons/protocol_api/core/legacy_simulator/legacy_protocol_core.py +14 -2
  76. opentrons/protocol_api/core/legacy_simulator/tasks.py +19 -0
  77. opentrons/protocol_api/core/module.py +58 -2
  78. opentrons/protocol_api/core/protocol.py +23 -2
  79. opentrons/protocol_api/core/tasks.py +31 -0
  80. opentrons/protocol_api/core/well.py +4 -0
  81. opentrons/protocol_api/instrument_context.py +388 -2
  82. opentrons/protocol_api/labware.py +10 -2
  83. opentrons/protocol_api/module_contexts.py +170 -6
  84. opentrons/protocol_api/protocol_context.py +87 -21
  85. opentrons/protocol_api/robot_context.py +41 -25
  86. opentrons/protocol_api/tasks.py +48 -0
  87. opentrons/protocol_api/validation.py +49 -3
  88. opentrons/protocol_engine/__init__.py +4 -0
  89. opentrons/protocol_engine/actions/__init__.py +6 -2
  90. opentrons/protocol_engine/actions/actions.py +31 -9
  91. opentrons/protocol_engine/clients/sync_client.py +42 -7
  92. opentrons/protocol_engine/commands/__init__.py +56 -0
  93. opentrons/protocol_engine/commands/absorbance_reader/close_lid.py +2 -15
  94. opentrons/protocol_engine/commands/absorbance_reader/open_lid.py +2 -15
  95. opentrons/protocol_engine/commands/absorbance_reader/read.py +22 -23
  96. opentrons/protocol_engine/commands/aspirate.py +1 -0
  97. opentrons/protocol_engine/commands/aspirate_while_tracking.py +52 -19
  98. opentrons/protocol_engine/commands/capture_image.py +302 -0
  99. opentrons/protocol_engine/commands/command.py +2 -0
  100. opentrons/protocol_engine/commands/command_unions.py +62 -0
  101. opentrons/protocol_engine/commands/create_timer.py +83 -0
  102. opentrons/protocol_engine/commands/dispense.py +1 -0
  103. opentrons/protocol_engine/commands/dispense_while_tracking.py +56 -19
  104. opentrons/protocol_engine/commands/drop_tip.py +32 -8
  105. opentrons/protocol_engine/commands/flex_stacker/common.py +35 -0
  106. opentrons/protocol_engine/commands/flex_stacker/set_stored_labware.py +7 -0
  107. opentrons/protocol_engine/commands/heater_shaker/__init__.py +14 -0
  108. opentrons/protocol_engine/commands/heater_shaker/common.py +20 -0
  109. opentrons/protocol_engine/commands/heater_shaker/set_and_wait_for_shake_speed.py +5 -4
  110. opentrons/protocol_engine/commands/heater_shaker/set_shake_speed.py +136 -0
  111. opentrons/protocol_engine/commands/heater_shaker/set_target_temperature.py +31 -5
  112. opentrons/protocol_engine/commands/move_labware.py +3 -4
  113. opentrons/protocol_engine/commands/move_to_addressable_area_for_drop_tip.py +1 -1
  114. opentrons/protocol_engine/commands/movement_common.py +31 -2
  115. opentrons/protocol_engine/commands/pick_up_tip.py +21 -11
  116. opentrons/protocol_engine/commands/pipetting_common.py +48 -3
  117. opentrons/protocol_engine/commands/set_tip_state.py +97 -0
  118. opentrons/protocol_engine/commands/temperature_module/set_target_temperature.py +38 -7
  119. opentrons/protocol_engine/commands/thermocycler/__init__.py +16 -0
  120. opentrons/protocol_engine/commands/thermocycler/run_extended_profile.py +6 -0
  121. opentrons/protocol_engine/commands/thermocycler/run_profile.py +8 -0
  122. opentrons/protocol_engine/commands/thermocycler/set_target_block_temperature.py +44 -7
  123. opentrons/protocol_engine/commands/thermocycler/set_target_lid_temperature.py +43 -14
  124. opentrons/protocol_engine/commands/thermocycler/start_run_extended_profile.py +191 -0
  125. opentrons/protocol_engine/commands/touch_tip.py +1 -1
  126. opentrons/protocol_engine/commands/unsafe/unsafe_place_labware.py +6 -22
  127. opentrons/protocol_engine/commands/wait_for_tasks.py +98 -0
  128. opentrons/protocol_engine/create_protocol_engine.py +12 -0
  129. opentrons/protocol_engine/engine_support.py +3 -0
  130. opentrons/protocol_engine/errors/__init__.py +12 -0
  131. opentrons/protocol_engine/errors/exceptions.py +119 -0
  132. opentrons/protocol_engine/execution/__init__.py +4 -0
  133. opentrons/protocol_engine/execution/command_executor.py +62 -1
  134. opentrons/protocol_engine/execution/create_queue_worker.py +9 -2
  135. opentrons/protocol_engine/execution/labware_movement.py +13 -15
  136. opentrons/protocol_engine/execution/movement.py +2 -0
  137. opentrons/protocol_engine/execution/pipetting.py +19 -25
  138. opentrons/protocol_engine/execution/queue_worker.py +4 -0
  139. opentrons/protocol_engine/execution/run_control.py +8 -0
  140. opentrons/protocol_engine/execution/task_handler.py +157 -0
  141. opentrons/protocol_engine/protocol_engine.py +137 -36
  142. opentrons/protocol_engine/resources/__init__.py +4 -0
  143. opentrons/protocol_engine/resources/camera_provider.py +110 -0
  144. opentrons/protocol_engine/resources/concurrency_provider.py +27 -0
  145. opentrons/protocol_engine/resources/deck_configuration_provider.py +7 -0
  146. opentrons/protocol_engine/resources/file_provider.py +133 -58
  147. opentrons/protocol_engine/resources/labware_validation.py +10 -6
  148. opentrons/protocol_engine/slot_standardization.py +2 -0
  149. opentrons/protocol_engine/state/_well_math.py +60 -18
  150. opentrons/protocol_engine/state/addressable_areas.py +2 -0
  151. opentrons/protocol_engine/state/camera.py +54 -0
  152. opentrons/protocol_engine/state/commands.py +37 -14
  153. opentrons/protocol_engine/state/geometry.py +276 -379
  154. opentrons/protocol_engine/state/labware.py +62 -108
  155. opentrons/protocol_engine/state/labware_origin_math/errors.py +94 -0
  156. opentrons/protocol_engine/state/labware_origin_math/stackup_origin_to_labware_origin.py +1336 -0
  157. opentrons/protocol_engine/state/module_substates/thermocycler_module_substate.py +37 -0
  158. opentrons/protocol_engine/state/modules.py +30 -8
  159. opentrons/protocol_engine/state/motion.py +44 -0
  160. opentrons/protocol_engine/state/preconditions.py +59 -0
  161. opentrons/protocol_engine/state/state.py +44 -0
  162. opentrons/protocol_engine/state/state_summary.py +4 -0
  163. opentrons/protocol_engine/state/tasks.py +139 -0
  164. opentrons/protocol_engine/state/tips.py +177 -258
  165. opentrons/protocol_engine/state/update_types.py +26 -9
  166. opentrons/protocol_engine/types/__init__.py +23 -4
  167. opentrons/protocol_engine/types/command_preconditions.py +18 -0
  168. opentrons/protocol_engine/types/deck_configuration.py +5 -1
  169. opentrons/protocol_engine/types/instrument.py +8 -1
  170. opentrons/protocol_engine/types/labware.py +1 -13
  171. opentrons/protocol_engine/types/location.py +26 -2
  172. opentrons/protocol_engine/types/module.py +11 -1
  173. opentrons/protocol_engine/types/tasks.py +38 -0
  174. opentrons/protocol_engine/types/tip.py +9 -0
  175. opentrons/protocol_runner/create_simulating_orchestrator.py +29 -2
  176. opentrons/protocol_runner/protocol_runner.py +14 -1
  177. opentrons/protocol_runner/run_orchestrator.py +49 -2
  178. opentrons/protocols/advanced_control/transfers/transfer_liquid_utils.py +2 -2
  179. opentrons/protocols/api_support/definitions.py +1 -1
  180. opentrons/protocols/api_support/types.py +2 -1
  181. opentrons/simulate.py +51 -15
  182. opentrons/system/camera.py +334 -4
  183. opentrons/system/ffmpeg.py +110 -0
  184. {opentrons-8.7.0a9.dist-info → opentrons-8.8.0a7.dist-info}/METADATA +4 -4
  185. {opentrons-8.7.0a9.dist-info → opentrons-8.8.0a7.dist-info}/RECORD +188 -160
  186. opentrons/protocol_engine/state/_labware_origin_math.py +0 -636
  187. {opentrons-8.7.0a9.dist-info → opentrons-8.8.0a7.dist-info}/WHEEL +0 -0
  188. {opentrons-8.7.0a9.dist-info → opentrons-8.8.0a7.dist-info}/entry_points.txt +0 -0
  189. {opentrons-8.7.0a9.dist-info → opentrons-8.8.0a7.dist-info}/licenses/LICENSE +0 -0
opentrons/simulate.py CHANGED
@@ -3,6 +3,7 @@
3
3
  This module has functions that provide a console entrypoint for simulating
4
4
  a protocol from the command line.
5
5
  """
6
+
6
7
  import argparse
7
8
  import asyncio
8
9
  import atexit
@@ -24,6 +25,7 @@ from typing import (
24
25
  BinaryIO,
25
26
  Optional,
26
27
  Union,
28
+ Iterator,
27
29
  )
28
30
  from typing_extensions import Literal
29
31
 
@@ -48,6 +50,7 @@ from opentrons.protocol_engine.create_protocol_engine import (
48
50
  from opentrons.protocol_engine import error_recovery_policy
49
51
  from opentrons.protocol_engine.state.config import Config
50
52
  from opentrons.protocol_engine.types import DeckType, EngineStatus, PostRunHardwareState
53
+ from opentrons.protocol_engine.resources.camera_provider import CameraProvider
51
54
  from opentrons.protocol_reader.protocol_source import ProtocolSource
52
55
  from opentrons.protocol_runner.protocol_runner import create_protocol_runner, LiveRunner
53
56
  from opentrons.protocol_runner import RunOrchestrator
@@ -81,6 +84,7 @@ if TYPE_CHECKING:
81
84
  from opentrons_shared_data.labware.types import (
82
85
  LabwareDefinition as LabwareDefinitionDict,
83
86
  )
87
+ from opentrons.protocol_engine import ProtocolEngine
84
88
 
85
89
 
86
90
  # See Jira RCORE-535.
@@ -309,6 +313,7 @@ def get_protocol_api(
309
313
  bundled_labware=bundled_labware,
310
314
  bundled_data=bundled_data,
311
315
  extra_labware=extra_labware,
316
+ clean_up_hardware=(hardware_simulator is None),
312
317
  )
313
318
  else:
314
319
  if bundled_labware is not None:
@@ -326,6 +331,7 @@ def get_protocol_api(
326
331
  bundled_data=bundled_data,
327
332
  extra_labware=extra_labware,
328
333
  use_pe_virtual_hardware=use_virtual_hardware,
334
+ clean_up_hardware=(hardware_simulator is None),
329
335
  )
330
336
 
331
337
  # Intentional difference from execute.get_protocol_api():
@@ -781,13 +787,15 @@ def _create_live_context_non_pe(
781
787
  extra_labware: Optional[Dict[str, "LabwareDefinitionDict"]],
782
788
  bundled_labware: Optional[Dict[str, "LabwareDefinitionDict"]],
783
789
  bundled_data: Optional[Dict[str, bytes]],
790
+ clean_up_hardware: bool,
784
791
  ) -> ProtocolContext:
785
792
  """Return a live ProtocolContext.
786
793
 
787
794
  This controls the robot through the older infrastructure, instead of through Protocol Engine.
788
795
  """
789
796
  assert api_version < ENGINE_CORE_API_VERSION
790
- return protocol_api.create_protocol_context(
797
+
798
+ ctx = protocol_api.create_protocol_context(
791
799
  api_version=api_version,
792
800
  deck_type=deck_type,
793
801
  hardware_api=hardware_api,
@@ -795,6 +803,17 @@ def _create_live_context_non_pe(
795
803
  bundled_data=bundled_data,
796
804
  extra_labware=extra_labware,
797
805
  )
806
+ # Hack: we need to hook the protocol context cleanup in a way that isn't safe to put
807
+ # in the context generally, so we can do it like this and feel sad
808
+ original_cleanup = ctx.cleanup
809
+
810
+ def _cleanup_hook() -> None:
811
+ if clean_up_hardware:
812
+ ctx._hw_manager.hardware.clean_up()
813
+ original_cleanup()
814
+
815
+ ctx.cleanup = _cleanup_hook # type: ignore[method-assign]
816
+ return ctx
798
817
 
799
818
 
800
819
  def _create_live_context_pe(
@@ -805,26 +824,41 @@ def _create_live_context_pe(
805
824
  extra_labware: Dict[str, "LabwareDefinitionDict"],
806
825
  bundled_data: Optional[Dict[str, bytes]],
807
826
  use_pe_virtual_hardware: bool = True,
827
+ clean_up_hardware: bool = True,
808
828
  ) -> ProtocolContext:
809
829
  """Return a live ProtocolContext that controls the robot through ProtocolEngine."""
810
830
  assert api_version >= ENGINE_CORE_API_VERSION
811
831
  hardware_api_wrapped = hardware_api.wrapped()
812
832
  global _LIVE_PROTOCOL_ENGINE_CONTEXTS
833
+
834
+ @contextmanager
835
+ def _cleanup_hardware_with_engine() -> (
836
+ Iterator[tuple["ProtocolEngine", asyncio.AbstractEventLoop]]
837
+ ):
838
+
839
+ try:
840
+ with create_protocol_engine_in_thread(
841
+ hardware_api=hardware_api_wrapped,
842
+ config=_get_protocol_engine_config(
843
+ robot_type, use_pe_virtual_hardware=use_pe_virtual_hardware
844
+ ),
845
+ deck_configuration=None,
846
+ file_provider=None,
847
+ error_recovery_policy=error_recovery_policy.never_recover,
848
+ drop_tips_after_run=False,
849
+ post_run_hardware_state=PostRunHardwareState.STAY_ENGAGED_IN_PLACE,
850
+ load_fixed_trash=should_load_fixed_trash_labware_for_python_protocol(
851
+ api_version
852
+ ),
853
+ camera_provider=CameraProvider(),
854
+ ) as (engine, loop):
855
+ yield engine, loop
856
+ finally:
857
+ if clean_up_hardware:
858
+ hardware_api.clean_up()
859
+
813
860
  pe, loop = _LIVE_PROTOCOL_ENGINE_CONTEXTS.enter_context(
814
- create_protocol_engine_in_thread(
815
- hardware_api=hardware_api_wrapped,
816
- config=_get_protocol_engine_config(
817
- robot_type, use_pe_virtual_hardware=use_pe_virtual_hardware
818
- ),
819
- deck_configuration=None,
820
- file_provider=None,
821
- error_recovery_policy=error_recovery_policy.never_recover,
822
- drop_tips_after_run=False,
823
- post_run_hardware_state=PostRunHardwareState.STAY_ENGAGED_IN_PLACE,
824
- load_fixed_trash=should_load_fixed_trash_labware_for_python_protocol(
825
- api_version
826
- ),
827
- )
861
+ _cleanup_hardware_with_engine()
828
862
  )
829
863
 
830
864
  # `async def` so we can use loop.run_coroutine_threadsafe() to wait for its completion.
@@ -879,6 +913,7 @@ def _run_file_non_pe(
879
913
  extra_labware=extra_labware,
880
914
  bundled_labware=bundled_labware,
881
915
  bundled_data=bundled_data,
916
+ clean_up_hardware=False,
882
917
  )
883
918
 
884
919
  scraper = _CommandScraper(logger=logger, level=level, broker=context.broker)
@@ -949,6 +984,7 @@ def _run_file_pe(
949
984
  protocol_live_runner=LiveRunner(
950
985
  protocol_engine=protocol_engine, hardware_api=hardware_api_wrapped
951
986
  ),
987
+ camera_provider=CameraProvider(),
952
988
  )
953
989
 
954
990
  # TODO(mm, 2024-08-06): This home is theoretically redundant with Protocol
@@ -1,10 +1,68 @@
1
1
  import asyncio
2
2
  import os
3
3
  from pathlib import Path
4
-
5
- from opentrons.config import ARCHITECTURE, SystemArchitecture
4
+ import logging
5
+ from functools import lru_cache
6
+ from enum import Enum
7
+ from typing import Dict, Optional
8
+ from opentrons.config import ARCHITECTURE, SystemArchitecture, get_opentrons_path
6
9
  from opentrons_shared_data.errors.exceptions import CommunicationError
7
10
  from opentrons_shared_data.errors.codes import ErrorCodes
11
+ from opentrons.config import IS_ROBOT
12
+ from opentrons_shared_data.robot.types import RobotType, RobotTypeEnum
13
+ from opentrons.protocol_engine.resources.camera_provider import (
14
+ CameraProvider,
15
+ ImageParameters,
16
+ CameraError,
17
+ CameraSettings,
18
+ )
19
+ from opentrons.system import ffmpeg
20
+
21
+ log = logging.getLogger(__name__)
22
+
23
+ # Default System Cameras
24
+ DEFAULT_SYSTEM_CAMERA = "/dev/ot_system_camera"
25
+
26
+ # Stream Globals
27
+ DEFAULT_CONF_FILE = (
28
+ "/lib/systemd/system/opentrons-live-stream/opentrons-live-stream.env"
29
+ )
30
+ STREAM_CONF_FILE_KEYS = [
31
+ "BOOT_ID",
32
+ "STATUS",
33
+ "SOURCE",
34
+ "RESOLUTION",
35
+ "FRAMERATE",
36
+ "BITRATE",
37
+ ]
38
+
39
+ # Camera Parameter Globals
40
+ RESOLUTION_MIN = (320, 240)
41
+ RESOLUTION_MAX = (7680, 4320)
42
+ RESOLUTION_DEFAULT = (1920, 1080)
43
+ ZOOM_MIN = 1.0
44
+ ZOOM_MAX = 2.0
45
+ ZOOM_DEFAULT = 1.0
46
+ CONTRAST_MIN = 0.0
47
+ CONTRAST_MAX = 2.0
48
+ CONTRAST_DEFAULT = 1.0
49
+ BRIGHTNESS_MIN = -128
50
+ BRIGHTNESS_MAX = 128
51
+ BRIGHTNESS_DEFAULT = 0
52
+ SATURATION_MIN = 0.0
53
+ SATURATION_MAX = 2.0
54
+ SATURATION_DEFAULT = 1.0
55
+
56
+
57
+ class StreamConfigurationKeys(str, Enum):
58
+ """The Configuration Key Types."""
59
+
60
+ BOOT_ID = "BOOT_ID"
61
+ STATUS = "STATUS"
62
+ SOURCE = "SOURCE"
63
+ RESOLUTION = "RESOLUTION"
64
+ FRAMERATE = "FRAMERATE"
65
+ BITRATE = "BITRATE"
8
66
 
9
67
 
10
68
  class CameraException(CommunicationError):
@@ -17,7 +75,7 @@ class CameraException(CommunicationError):
17
75
 
18
76
 
19
77
  async def take_picture(filename: Path) -> None:
20
- """Take a picture and save it to filename
78
+ """Legacy method to take a picture and save it to filename
21
79
 
22
80
  :param filename: Name of file to save picture to
23
81
  :param loop: optional loop to use
@@ -30,7 +88,7 @@ async def take_picture(filename: Path) -> None:
30
88
  pass
31
89
 
32
90
  if ARCHITECTURE == SystemArchitecture.YOCTO:
33
- cmd = f"v4l2-ctl --device /dev/video2 --set-fmt-video=width=1280,height=720,pixelformat=MJPG --stream-mmap --stream-to={str(filename)} --stream-count=1"
91
+ cmd = f"v4l2-ctl --device /dev/ot_system_camera --set-fmt-video=width=1280,height=720,pixelformat=MJPG --stream-mmap --stream-to={str(filename)} --stream-count=1"
34
92
  elif ARCHITECTURE == SystemArchitecture.BUILDROOT:
35
93
  cmd = f"ffmpeg -f video4linux2 -s 640x480 -i /dev/video0 -ss 0:0:1 -frames 1 {str(filename)}"
36
94
  else: # HOST
@@ -49,3 +107,275 @@ async def take_picture(filename: Path) -> None:
49
107
  raise CameraException("Failed to communicate with camera", res)
50
108
  if not filename.exists():
51
109
  raise CameraException("Failed to save image", "")
110
+
111
+
112
+ def get_stream_configuration_filepath() -> Path:
113
+ """Return the file path to the Opentrons Live Stream Configuration file."""
114
+ filepath = get_opentrons_path("live_stream_environment_file")
115
+ if IS_ROBOT and not os.path.exists(filepath):
116
+ # If the dynamic configuration file doesn't exist make it using our defaults file
117
+ with open(DEFAULT_CONF_FILE, "r") as default_config:
118
+ content = default_config.read()
119
+ with open(filepath, "w") as new_config_file:
120
+ new_config_file.write(content)
121
+ return filepath
122
+
123
+
124
+ def robot_supports_livestream(robot_type: RobotType) -> bool:
125
+ """Validate whether or not robot supports live streaming service."""
126
+ robot = RobotTypeEnum.robot_literal_to_enum(robot_type)
127
+ if robot == RobotTypeEnum.OT2:
128
+ # If we are on an OT-2 we do not support live streams
129
+ return False
130
+ return True
131
+
132
+
133
+ async def update_live_stream_status(
134
+ robot_type: RobotType,
135
+ stream_status: bool,
136
+ camera_provider: CameraProvider,
137
+ override_settings: Optional[CameraSettings] = None,
138
+ ) -> None:
139
+ """Update and handle a change in the Opentrons Live Stream status."""
140
+ if not IS_ROBOT or robot_supports_livestream(robot_type) is False:
141
+ # If we are not on a robot we simply no-op updating the stream
142
+ return None
143
+
144
+ contents = load_stream_configuration_file_data()
145
+ if contents is None:
146
+ log.error("Opentrons Live Stream Configuration file cannot be updated.")
147
+ return None
148
+
149
+ # Validate the stream status
150
+ if override_settings is not None:
151
+ camera_enable_settings = override_settings
152
+ else:
153
+ camera_enable_settings = await camera_provider.get_camera_settings()
154
+ status = "OFF"
155
+ if (
156
+ stream_status
157
+ and camera_enable_settings.cameraEnabled
158
+ and camera_enable_settings.liveStreamEnabled
159
+ ):
160
+ # Check to see if the camera device is available
161
+ raw_device = str(contents["SOURCE"])[1:-1]
162
+ if not os.path.exists(raw_device):
163
+ log.error(
164
+ f"Opentrons Live Stream cannot sample the camera. No video device found with device path: {raw_device}"
165
+ )
166
+ # Enable the stream
167
+ status = "ON"
168
+ # Overwrite the contents
169
+ contents["BOOT_ID"] = get_boot_id()
170
+ contents["STATUS"] = status
171
+ write_stream_configuration_file_data(contents)
172
+ await restart_live_stream(robot_type)
173
+
174
+
175
+ async def stop_live_stream(robot_type: RobotType) -> None:
176
+ """Attempt to stop the Opentrons Live Stream service."""
177
+ if robot_supports_livestream(robot_type) is False:
178
+ # No-op on OT-2 since we don't have a live stream service there
179
+ return None
180
+
181
+ command = ["systemctl", "stop", "opentrons-live-stream"]
182
+ subprocess = await asyncio.create_subprocess_exec(
183
+ *command,
184
+ stdout=asyncio.subprocess.PIPE,
185
+ stderr=asyncio.subprocess.PIPE,
186
+ )
187
+ stdout, stderr = await subprocess.communicate()
188
+ if subprocess.returncode == 0:
189
+ log.info("Stopped the opentrons-live-stream service.")
190
+ else:
191
+ log.error(
192
+ f"Failed to stop opentrons-live-stream, returncode:{ subprocess.returncode}, stdout: {stdout.decode()}, stderr: {stderr.decode()}"
193
+ )
194
+
195
+
196
+ async def restart_live_stream(robot_type: RobotType) -> None:
197
+ """Attempt to restart the Opentrons Live Stream service."""
198
+ if robot_supports_livestream(robot_type) is False:
199
+ # No-op on OT-2 since we don't have a live stream service there
200
+ return None
201
+
202
+ command = ["systemctl", "restart", "opentrons-live-stream"]
203
+ subprocess = await asyncio.create_subprocess_exec(
204
+ *command,
205
+ stdout=asyncio.subprocess.PIPE,
206
+ stderr=asyncio.subprocess.PIPE,
207
+ )
208
+ stdout, stderr = await subprocess.communicate()
209
+ if subprocess.returncode == 0:
210
+ log.info("Restarted opentrons-live-stream service.")
211
+ else:
212
+ log.error(
213
+ f"Failed to restart opentrons-live-stream, returncode:{ subprocess.returncode}, stdout: {stdout.decode()}, stderr: {stderr.decode()}"
214
+ )
215
+
216
+
217
+ def load_stream_configuration_file_data() -> dict[str, str] | None:
218
+ """Load the Opentrons Live Stream Conf file and return parsed data or None if an error occurs."""
219
+ src = get_stream_configuration_filepath()
220
+ if not src.exists():
221
+ log.error(f"Opentrons Live Stream configuration file not found: {src}")
222
+ return None
223
+ with src.open("rb") as fd:
224
+ try:
225
+ return parse_stream_configuration_file_data(fd.read())
226
+ except Exception as e:
227
+ log.error(
228
+ f"Opentrons Live Stream status update file parsing failed with: {e}"
229
+ )
230
+ return None
231
+
232
+
233
+ def parse_stream_configuration_file_data(data: bytes) -> Dict[str, str] | None:
234
+ """
235
+ Parse a collect of bytes for Opentrons Live Stream Configuration data and return a dictionary of
236
+ results keyed by configuration constants. Returns None if an error occurred during parsing.
237
+ """
238
+ contents: Dict[str, str] = {
239
+ key.decode("utf-8"): val.decode("utf-8")
240
+ for key, val in [line.split(b"=") for line in data.split(b"\n") if b"=" in line]
241
+ }
242
+
243
+ enum_stream_keys = {stream_key.value for stream_key in StreamConfigurationKeys}
244
+ if sorted(list(contents.keys())) != sorted(enum_stream_keys):
245
+ log.error(
246
+ "Opentrons Live Stream Configuration file data is incorrect or missing."
247
+ )
248
+ # We don't want to write bad or incomplete data to the file
249
+ return None
250
+
251
+ # Migrate old camera default file data to new uniform default
252
+ if contents[StreamConfigurationKeys.SOURCE] == "NONE":
253
+ contents[StreamConfigurationKeys.SOURCE] = DEFAULT_SYSTEM_CAMERA
254
+ return contents
255
+
256
+
257
+ def write_stream_configuration_file_data(data: Dict[str, str]) -> None:
258
+ src = get_stream_configuration_filepath()
259
+ if not src.exists():
260
+ log.error(f"Opentrons Live Stream configuration file not found: {src}")
261
+ return None
262
+
263
+ enum_stream_keys = {stream_key.value for stream_key in StreamConfigurationKeys}
264
+ if sorted(list(data.keys())) != sorted(enum_stream_keys):
265
+ log.error(
266
+ "Data provided to write is not compatible with Opentrons Live Stream Configuration file."
267
+ )
268
+ return None
269
+
270
+ with src.open("w") as fd:
271
+ file_lines = [
272
+ f"{StreamConfigurationKeys.BOOT_ID}={data[StreamConfigurationKeys.BOOT_ID]}\n",
273
+ f"{StreamConfigurationKeys.STATUS}={data[StreamConfigurationKeys.STATUS]}\n",
274
+ f"{StreamConfigurationKeys.SOURCE}={data[StreamConfigurationKeys.SOURCE]}\n",
275
+ f"{StreamConfigurationKeys.RESOLUTION}={data[StreamConfigurationKeys.RESOLUTION]}\n",
276
+ f"{StreamConfigurationKeys.FRAMERATE}={data[StreamConfigurationKeys.FRAMERATE]}\n",
277
+ f"{StreamConfigurationKeys.BITRATE}={data[StreamConfigurationKeys.BITRATE]}\n",
278
+ ]
279
+ fd.writelines(file_lines)
280
+
281
+
282
+ async def image_capture( # noqa: C901
283
+ robot_type: RobotType, parameters: ImageParameters
284
+ ) -> bytes | CameraError:
285
+ """Process an Image Capture request with a Camera utilizing a given set of parameters."""
286
+ camera = DEFAULT_SYSTEM_CAMERA
287
+
288
+ # We must always validate the camera exists
289
+ if not os.path.exists(camera):
290
+ return CameraError(
291
+ message=f"No video device found with device path {camera}", code=None
292
+ )
293
+
294
+ if parameters.zoom is not None and (
295
+ parameters.zoom < ZOOM_MIN or parameters.zoom > ZOOM_MAX
296
+ ):
297
+ potential_invalid_param = "Zoom"
298
+ elif parameters.contrast is not None and (
299
+ parameters.contrast < CONTRAST_MIN or parameters.contrast > CONTRAST_MAX
300
+ ):
301
+ potential_invalid_param = "Contrast"
302
+ elif parameters.brightness is not None and (
303
+ parameters.brightness < BRIGHTNESS_MIN or parameters.brightness > BRIGHTNESS_MAX
304
+ ):
305
+ potential_invalid_param = "Brightness"
306
+ elif parameters.saturation is not None and (
307
+ parameters.saturation < SATURATION_MIN or parameters.saturation > SATURATION_MAX
308
+ ):
309
+ potential_invalid_param = "Saturation"
310
+ elif parameters.resolution is not None and (
311
+ parameters.resolution[0] < RESOLUTION_MIN[0]
312
+ or parameters.resolution[1] < RESOLUTION_MIN[1]
313
+ or parameters.resolution[0] > RESOLUTION_MAX[0]
314
+ or parameters.resolution[1] > RESOLUTION_MAX[1]
315
+ ):
316
+ potential_invalid_param = "Resolution"
317
+ else:
318
+ potential_invalid_param = None
319
+
320
+ if potential_invalid_param is not None:
321
+ return CameraError(
322
+ message=f"{potential_invalid_param} parameter is outside the boundaries allowed for image capture.",
323
+ code="IMAGE_SETTINGS",
324
+ )
325
+ try:
326
+ # Always stop the live stream service to ensure the Camera is always free when attempting an image capture
327
+ await stop_live_stream(robot_type)
328
+
329
+ zoom = parameters.zoom if parameters.zoom is not None else ZOOM_DEFAULT
330
+ contrast = (
331
+ parameters.contrast if parameters.contrast is not None else CONTRAST_DEFAULT
332
+ )
333
+ brightness = (
334
+ parameters.brightness
335
+ if parameters.brightness is not None
336
+ else BRIGHTNESS_DEFAULT
337
+ )
338
+ saturation = (
339
+ parameters.saturation
340
+ if parameters.saturation is not None
341
+ else SATURATION_DEFAULT
342
+ )
343
+ resolution = (
344
+ parameters.resolution
345
+ if parameters.resolution is not None
346
+ else RESOLUTION_DEFAULT
347
+ )
348
+
349
+ result = await ffmpeg.ffmpeg_capture_image_bytes(
350
+ robot_type=robot_type,
351
+ resolution=resolution,
352
+ camera=camera,
353
+ zoom=zoom,
354
+ pan=parameters.pan if parameters.pan is not None else (0, 0),
355
+ contrast=contrast,
356
+ brightness=brightness,
357
+ saturation=saturation,
358
+ )
359
+ except Exception:
360
+ result = CameraError(
361
+ message="Exception occured during execution of system image capture.",
362
+ code=None,
363
+ )
364
+ finally:
365
+ # Restart the live stream service
366
+ await restart_live_stream(robot_type)
367
+ return result
368
+
369
+
370
+ @lru_cache(maxsize=1)
371
+ def get_boot_id() -> str:
372
+ if IS_ROBOT:
373
+ return Path("/proc/sys/kernel/random/boot_id").read_text().strip()
374
+ else:
375
+ return "SIMULATED_BOOT_ID"
376
+
377
+
378
+ def camera_exists() -> bool:
379
+ """Validate whether or not the camera device exists."""
380
+ return os.path.exists(DEFAULT_SYSTEM_CAMERA)
381
+ # todo(chb, 2025-11-10): Eventually when we support multiple cameras this should accept a camera parameter to check for
@@ -0,0 +1,110 @@
1
+ """opentrons.system.ffmpeg: Functions and data for interacting with FFMPEG."""
2
+ import asyncio
3
+ import logging
4
+ from typing import Tuple
5
+ from opentrons.protocol_engine.resources.camera_provider import CameraError
6
+ from opentrons_shared_data.robot.types import RobotType
7
+
8
+ log = logging.getLogger(__name__)
9
+
10
+ # === FFMPEG Filter Details ===
11
+ # The following filters are utilized via the '-vf' flag to manipulate the final image returned:
12
+ # 'crop' = [output_width]:[output_height]:x:y
13
+ # The crop is composed of a desired output width and height for the image, and
14
+ # an X/Y position to begin the crop at (becomes the top left of the new image).
15
+ # 'scale' = [width]:[height]
16
+ # The resolution of the final image to export, scales up or down based on configuration.
17
+ # 'lut' (Look-Up Table) = 'y' (Luminance) = 'val' (Current value of a given pixel from 0-255)
18
+ # The equation on the Look up table takes the current luminance value of an image per pixel
19
+ # and manipulates it using Contrast and Brightness settings. This is applied to the whole image.
20
+ # 'hue' (Image color range) = 's' (Saturation) = [range]
21
+ # The hue flag uses the 's' (saturation) modifier to scale image color intensity, default is 1.
22
+
23
+ # todo(chb, 2025-10-13): Right now we're just zooming towards the center of the frame. The 'pan'
24
+ # setting should be used on the latter half of 'crop' to determine our cropping location instead.
25
+
26
+
27
+ async def ffmpeg_capture_image_bytes(
28
+ robot_type: RobotType,
29
+ resolution: Tuple[int, int],
30
+ camera: str,
31
+ zoom: float,
32
+ pan: Tuple[int, int],
33
+ contrast: float,
34
+ brightness: int,
35
+ saturation: float,
36
+ ) -> bytes | CameraError:
37
+ """Execute an FFMPEG command to capture an image based on various image parameters."""
38
+ if robot_type == "OT-2 Standard":
39
+ ot2_brightness: float = (
40
+ brightness / 128
41
+ ) * -1 # OT-2's equilizer field takes a value of -1.0 to 1.0 for brightness
42
+ command = [
43
+ "ffmpeg",
44
+ "-hwaccel",
45
+ "auto",
46
+ "-video_size",
47
+ f"{resolution[0]}x{resolution[1]}",
48
+ "-f",
49
+ "v4l2",
50
+ "-i",
51
+ f"{camera}",
52
+ "-vf",
53
+ f"crop=iw/{zoom}:ih/{zoom}:(iw-iw/{zoom})/{zoom}:(ih-ih/{zoom})/{zoom},"
54
+ f"scale={resolution[0]}:{resolution[1]},"
55
+ f"eq=brightness={ot2_brightness}:contrast={contrast}:saturation={saturation}",
56
+ "-frames:v",
57
+ "1",
58
+ "-f",
59
+ "image2pipe",
60
+ "-vcodec",
61
+ "mjpeg",
62
+ "-",
63
+ ]
64
+ else:
65
+ command = [
66
+ "ffmpeg",
67
+ "-hwaccel",
68
+ "auto",
69
+ "-video_size",
70
+ f"{resolution[0]}x{resolution[1]}",
71
+ "-f",
72
+ "v4l2",
73
+ "-i",
74
+ f"{camera}",
75
+ "-vf",
76
+ f"crop=iw/{zoom}:ih/{zoom}:(iw-iw/{zoom})/{zoom}:(ih-ih/{zoom})/{zoom},"
77
+ f"scale={resolution[0]}:{resolution[1]},"
78
+ f"lut=y=(val-128)*{contrast}+128-{brightness},"
79
+ f"hue=s={saturation},format=nv12",
80
+ "-frames:v",
81
+ "1",
82
+ "-f",
83
+ "image2pipe",
84
+ "-vcodec",
85
+ "mjpeg",
86
+ "-",
87
+ ]
88
+
89
+ subprocess = await asyncio.create_subprocess_exec(
90
+ *command,
91
+ stdout=asyncio.subprocess.PIPE,
92
+ stderr=asyncio.subprocess.PIPE,
93
+ )
94
+ stdout: bytes
95
+ stderr: bytes
96
+ stdout, stderr = await subprocess.communicate()
97
+ if subprocess.returncode == 0:
98
+ log.info("Successfully captured an image with camera.")
99
+ # Upon success, dump our byte stream to the result
100
+ return stdout
101
+ else:
102
+ log.error(
103
+ f"Failed to capture an image with camera, returncode:{ subprocess.returncode}, stdout: {stdout.decode()}, stderr: {stderr.decode()}"
104
+ )
105
+ return CameraError(
106
+ message="Failed to return bytes from FFMPEG image capture.",
107
+ code=str(subprocess.returncode)
108
+ if subprocess.returncode is not None
109
+ else None,
110
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: opentrons
3
- Version: 8.7.0a9
3
+ Version: 8.8.0a7
4
4
  Summary: The Opentrons API is a simple framework designed to make writing automated biology lab protocols easy.
5
5
  Project-URL: opentrons.com, https://www.opentrons.com
6
6
  Project-URL: Source Code On Github, https://github.com/Opentrons/opentrons/tree/edge/api
@@ -24,7 +24,7 @@ Requires-Dist: click<9,>=8.0.0
24
24
  Requires-Dist: importlib-metadata>=1.0; python_version < '3.8'
25
25
  Requires-Dist: jsonschema<4.18.0,>=3.0.1
26
26
  Requires-Dist: numpy<2,>=1.20.0
27
- Requires-Dist: opentrons-shared-data==8.7.0a9
27
+ Requires-Dist: opentrons-shared-data==8.8.0a7
28
28
  Requires-Dist: packaging>=21.0
29
29
  Requires-Dist: pydantic-settings<3,>=2
30
30
  Requires-Dist: pydantic<3,>=2.0.0
@@ -32,6 +32,6 @@ Requires-Dist: pyserial>=3.5
32
32
  Requires-Dist: pyusb==1.2.1
33
33
  Requires-Dist: typing-extensions<5,>=4.0.0
34
34
  Provides-Extra: flex-hardware
35
- Requires-Dist: opentrons-hardware[flex]==8.7.0a9; extra == 'flex-hardware'
35
+ Requires-Dist: opentrons-hardware[flex]==8.8.0a7; extra == 'flex-hardware'
36
36
  Provides-Extra: ot2-hardware
37
- Requires-Dist: opentrons-hardware==8.7.0a9; extra == 'ot2-hardware'
37
+ Requires-Dist: opentrons-hardware==8.8.0a7; extra == 'ot2-hardware'