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.
- opentrons/_version.py +2 -2
- opentrons/cli/analyze.py +4 -1
- opentrons/config/__init__.py +7 -0
- opentrons/drivers/asyncio/communication/serial_connection.py +126 -49
- opentrons/drivers/heater_shaker/abstract.py +5 -0
- opentrons/drivers/heater_shaker/driver.py +10 -0
- opentrons/drivers/heater_shaker/simulator.py +4 -0
- opentrons/drivers/thermocycler/abstract.py +6 -0
- opentrons/drivers/thermocycler/driver.py +61 -10
- opentrons/drivers/thermocycler/simulator.py +6 -0
- opentrons/drivers/vacuum_module/__init__.py +5 -0
- opentrons/drivers/vacuum_module/abstract.py +93 -0
- opentrons/drivers/vacuum_module/driver.py +208 -0
- opentrons/drivers/vacuum_module/errors.py +39 -0
- opentrons/drivers/vacuum_module/simulator.py +85 -0
- opentrons/drivers/vacuum_module/types.py +79 -0
- opentrons/execute.py +3 -0
- opentrons/hardware_control/api.py +24 -5
- opentrons/hardware_control/backends/controller.py +8 -2
- opentrons/hardware_control/backends/flex_protocol.py +1 -0
- opentrons/hardware_control/backends/ot3controller.py +35 -2
- opentrons/hardware_control/backends/ot3simulator.py +3 -1
- opentrons/hardware_control/backends/ot3utils.py +37 -0
- opentrons/hardware_control/backends/simulator.py +2 -1
- opentrons/hardware_control/backends/subsystem_manager.py +5 -2
- opentrons/hardware_control/emulation/abstract_emulator.py +6 -4
- opentrons/hardware_control/emulation/connection_handler.py +8 -5
- opentrons/hardware_control/emulation/heater_shaker.py +12 -3
- opentrons/hardware_control/emulation/settings.py +1 -1
- opentrons/hardware_control/emulation/thermocycler.py +67 -15
- opentrons/hardware_control/module_control.py +105 -10
- opentrons/hardware_control/modules/__init__.py +3 -0
- opentrons/hardware_control/modules/absorbance_reader.py +11 -4
- opentrons/hardware_control/modules/flex_stacker.py +38 -9
- opentrons/hardware_control/modules/heater_shaker.py +42 -5
- opentrons/hardware_control/modules/magdeck.py +8 -4
- opentrons/hardware_control/modules/mod_abc.py +14 -6
- opentrons/hardware_control/modules/tempdeck.py +25 -5
- opentrons/hardware_control/modules/thermocycler.py +68 -11
- opentrons/hardware_control/modules/types.py +20 -1
- opentrons/hardware_control/modules/utils.py +11 -4
- opentrons/hardware_control/motion_utilities.py +6 -6
- opentrons/hardware_control/nozzle_manager.py +3 -0
- opentrons/hardware_control/ot3api.py +85 -17
- opentrons/hardware_control/poller.py +22 -8
- opentrons/hardware_control/protocols/liquid_handler.py +6 -2
- opentrons/hardware_control/scripts/update_module_fw.py +5 -0
- opentrons/hardware_control/types.py +43 -2
- opentrons/legacy_commands/commands.py +58 -5
- opentrons/legacy_commands/module_commands.py +52 -0
- opentrons/legacy_commands/protocol_commands.py +53 -1
- opentrons/legacy_commands/types.py +155 -1
- opentrons/motion_planning/deck_conflict.py +17 -12
- opentrons/motion_planning/waypoints.py +15 -29
- opentrons/protocol_api/__init__.py +5 -1
- opentrons/protocol_api/_transfer_liquid_validation.py +17 -2
- opentrons/protocol_api/_types.py +8 -1
- opentrons/protocol_api/core/common.py +3 -1
- opentrons/protocol_api/core/engine/_default_labware_versions.py +33 -11
- opentrons/protocol_api/core/engine/deck_conflict.py +3 -1
- opentrons/protocol_api/core/engine/instrument.py +109 -26
- opentrons/protocol_api/core/engine/labware.py +8 -1
- opentrons/protocol_api/core/engine/module_core.py +95 -4
- opentrons/protocol_api/core/engine/protocol.py +51 -2
- opentrons/protocol_api/core/engine/stringify.py +2 -0
- opentrons/protocol_api/core/engine/tasks.py +48 -0
- opentrons/protocol_api/core/engine/well.py +8 -0
- opentrons/protocol_api/core/instrument.py +19 -2
- opentrons/protocol_api/core/legacy/legacy_instrument_core.py +19 -2
- opentrons/protocol_api/core/legacy/legacy_module_core.py +33 -2
- opentrons/protocol_api/core/legacy/legacy_protocol_core.py +23 -1
- opentrons/protocol_api/core/legacy/legacy_well_core.py +4 -0
- opentrons/protocol_api/core/legacy/tasks.py +19 -0
- opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py +19 -2
- opentrons/protocol_api/core/legacy_simulator/legacy_protocol_core.py +14 -2
- opentrons/protocol_api/core/legacy_simulator/tasks.py +19 -0
- opentrons/protocol_api/core/module.py +58 -2
- opentrons/protocol_api/core/protocol.py +23 -2
- opentrons/protocol_api/core/tasks.py +31 -0
- opentrons/protocol_api/core/well.py +4 -0
- opentrons/protocol_api/instrument_context.py +388 -2
- opentrons/protocol_api/labware.py +10 -2
- opentrons/protocol_api/module_contexts.py +170 -6
- opentrons/protocol_api/protocol_context.py +87 -21
- opentrons/protocol_api/robot_context.py +41 -25
- opentrons/protocol_api/tasks.py +48 -0
- opentrons/protocol_api/validation.py +49 -3
- opentrons/protocol_engine/__init__.py +4 -0
- opentrons/protocol_engine/actions/__init__.py +6 -2
- opentrons/protocol_engine/actions/actions.py +31 -9
- opentrons/protocol_engine/clients/sync_client.py +42 -7
- opentrons/protocol_engine/commands/__init__.py +56 -0
- opentrons/protocol_engine/commands/absorbance_reader/close_lid.py +2 -15
- opentrons/protocol_engine/commands/absorbance_reader/open_lid.py +2 -15
- opentrons/protocol_engine/commands/absorbance_reader/read.py +22 -23
- opentrons/protocol_engine/commands/aspirate.py +1 -0
- opentrons/protocol_engine/commands/aspirate_while_tracking.py +52 -19
- opentrons/protocol_engine/commands/capture_image.py +302 -0
- opentrons/protocol_engine/commands/command.py +2 -0
- opentrons/protocol_engine/commands/command_unions.py +62 -0
- opentrons/protocol_engine/commands/create_timer.py +83 -0
- opentrons/protocol_engine/commands/dispense.py +1 -0
- opentrons/protocol_engine/commands/dispense_while_tracking.py +56 -19
- opentrons/protocol_engine/commands/drop_tip.py +32 -8
- opentrons/protocol_engine/commands/flex_stacker/common.py +35 -0
- opentrons/protocol_engine/commands/flex_stacker/set_stored_labware.py +7 -0
- opentrons/protocol_engine/commands/heater_shaker/__init__.py +14 -0
- opentrons/protocol_engine/commands/heater_shaker/common.py +20 -0
- opentrons/protocol_engine/commands/heater_shaker/set_and_wait_for_shake_speed.py +5 -4
- opentrons/protocol_engine/commands/heater_shaker/set_shake_speed.py +136 -0
- opentrons/protocol_engine/commands/heater_shaker/set_target_temperature.py +31 -5
- opentrons/protocol_engine/commands/move_labware.py +3 -4
- opentrons/protocol_engine/commands/move_to_addressable_area_for_drop_tip.py +1 -1
- opentrons/protocol_engine/commands/movement_common.py +31 -2
- opentrons/protocol_engine/commands/pick_up_tip.py +21 -11
- opentrons/protocol_engine/commands/pipetting_common.py +48 -3
- opentrons/protocol_engine/commands/set_tip_state.py +97 -0
- opentrons/protocol_engine/commands/temperature_module/set_target_temperature.py +38 -7
- opentrons/protocol_engine/commands/thermocycler/__init__.py +16 -0
- opentrons/protocol_engine/commands/thermocycler/run_extended_profile.py +6 -0
- opentrons/protocol_engine/commands/thermocycler/run_profile.py +8 -0
- opentrons/protocol_engine/commands/thermocycler/set_target_block_temperature.py +44 -7
- opentrons/protocol_engine/commands/thermocycler/set_target_lid_temperature.py +43 -14
- opentrons/protocol_engine/commands/thermocycler/start_run_extended_profile.py +191 -0
- opentrons/protocol_engine/commands/touch_tip.py +1 -1
- opentrons/protocol_engine/commands/unsafe/unsafe_place_labware.py +6 -22
- opentrons/protocol_engine/commands/wait_for_tasks.py +98 -0
- opentrons/protocol_engine/create_protocol_engine.py +12 -0
- opentrons/protocol_engine/engine_support.py +3 -0
- opentrons/protocol_engine/errors/__init__.py +12 -0
- opentrons/protocol_engine/errors/exceptions.py +119 -0
- opentrons/protocol_engine/execution/__init__.py +4 -0
- opentrons/protocol_engine/execution/command_executor.py +62 -1
- opentrons/protocol_engine/execution/create_queue_worker.py +9 -2
- opentrons/protocol_engine/execution/labware_movement.py +13 -15
- opentrons/protocol_engine/execution/movement.py +2 -0
- opentrons/protocol_engine/execution/pipetting.py +19 -25
- opentrons/protocol_engine/execution/queue_worker.py +4 -0
- opentrons/protocol_engine/execution/run_control.py +8 -0
- opentrons/protocol_engine/execution/task_handler.py +157 -0
- opentrons/protocol_engine/protocol_engine.py +137 -36
- opentrons/protocol_engine/resources/__init__.py +4 -0
- opentrons/protocol_engine/resources/camera_provider.py +110 -0
- opentrons/protocol_engine/resources/concurrency_provider.py +27 -0
- opentrons/protocol_engine/resources/deck_configuration_provider.py +7 -0
- opentrons/protocol_engine/resources/file_provider.py +133 -58
- opentrons/protocol_engine/resources/labware_validation.py +10 -6
- opentrons/protocol_engine/slot_standardization.py +2 -0
- opentrons/protocol_engine/state/_well_math.py +60 -18
- opentrons/protocol_engine/state/addressable_areas.py +2 -0
- opentrons/protocol_engine/state/camera.py +54 -0
- opentrons/protocol_engine/state/commands.py +37 -14
- opentrons/protocol_engine/state/geometry.py +276 -379
- opentrons/protocol_engine/state/labware.py +62 -108
- opentrons/protocol_engine/state/labware_origin_math/errors.py +94 -0
- opentrons/protocol_engine/state/labware_origin_math/stackup_origin_to_labware_origin.py +1336 -0
- opentrons/protocol_engine/state/module_substates/thermocycler_module_substate.py +37 -0
- opentrons/protocol_engine/state/modules.py +30 -8
- opentrons/protocol_engine/state/motion.py +44 -0
- opentrons/protocol_engine/state/preconditions.py +59 -0
- opentrons/protocol_engine/state/state.py +44 -0
- opentrons/protocol_engine/state/state_summary.py +4 -0
- opentrons/protocol_engine/state/tasks.py +139 -0
- opentrons/protocol_engine/state/tips.py +177 -258
- opentrons/protocol_engine/state/update_types.py +26 -9
- opentrons/protocol_engine/types/__init__.py +23 -4
- opentrons/protocol_engine/types/command_preconditions.py +18 -0
- opentrons/protocol_engine/types/deck_configuration.py +5 -1
- opentrons/protocol_engine/types/instrument.py +8 -1
- opentrons/protocol_engine/types/labware.py +1 -13
- opentrons/protocol_engine/types/location.py +26 -2
- opentrons/protocol_engine/types/module.py +11 -1
- opentrons/protocol_engine/types/tasks.py +38 -0
- opentrons/protocol_engine/types/tip.py +9 -0
- opentrons/protocol_runner/create_simulating_orchestrator.py +29 -2
- opentrons/protocol_runner/protocol_runner.py +14 -1
- opentrons/protocol_runner/run_orchestrator.py +49 -2
- opentrons/protocols/advanced_control/transfers/transfer_liquid_utils.py +2 -2
- opentrons/protocols/api_support/definitions.py +1 -1
- opentrons/protocols/api_support/types.py +2 -1
- opentrons/simulate.py +51 -15
- opentrons/system/camera.py +334 -4
- opentrons/system/ffmpeg.py +110 -0
- {opentrons-8.7.0a9.dist-info → opentrons-8.8.0a7.dist-info}/METADATA +4 -4
- {opentrons-8.7.0a9.dist-info → opentrons-8.8.0a7.dist-info}/RECORD +188 -160
- opentrons/protocol_engine/state/_labware_origin_math.py +0 -636
- {opentrons-8.7.0a9.dist-info → opentrons-8.8.0a7.dist-info}/WHEEL +0 -0
- {opentrons-8.7.0a9.dist-info → opentrons-8.8.0a7.dist-info}/entry_points.txt +0 -0
- {opentrons-8.7.0a9.dist-info → opentrons-8.8.0a7.dist-info}/licenses/LICENSE +0 -0
|
@@ -52,6 +52,7 @@ from opentrons_shared_data.errors.exceptions import (
|
|
|
52
52
|
InvalidActuator,
|
|
53
53
|
FirmwareUpdateFailedError,
|
|
54
54
|
PipetteLiquidNotFoundError,
|
|
55
|
+
PipetteOverpressureError,
|
|
55
56
|
)
|
|
56
57
|
|
|
57
58
|
from .util import use_or_initialize_loop, check_motion_bounds
|
|
@@ -74,6 +75,8 @@ from .types import (
|
|
|
74
75
|
DoorStateNotification,
|
|
75
76
|
ErrorMessageNotification,
|
|
76
77
|
HardwareEvent,
|
|
78
|
+
AsynchronousModuleErrorNotification,
|
|
79
|
+
ModuleDisconnectedNotification,
|
|
77
80
|
HardwareEventHandler,
|
|
78
81
|
HardwareAction,
|
|
79
82
|
HepaFanState,
|
|
@@ -367,6 +370,24 @@ class OT3API(
|
|
|
367
370
|
|
|
368
371
|
return futures
|
|
369
372
|
|
|
373
|
+
def _send_module_notification(self, event: HardwareEvent) -> None:
|
|
374
|
+
if not isinstance(
|
|
375
|
+
event,
|
|
376
|
+
(
|
|
377
|
+
AsynchronousModuleErrorNotification,
|
|
378
|
+
ModuleDisconnectedNotification,
|
|
379
|
+
),
|
|
380
|
+
):
|
|
381
|
+
return
|
|
382
|
+
mod_log.info(
|
|
383
|
+
f"Forwarding module event {event.event} for {event.module_model} {event.module_serial} at {event.port}"
|
|
384
|
+
)
|
|
385
|
+
for cb in self._callbacks:
|
|
386
|
+
try:
|
|
387
|
+
cb(event)
|
|
388
|
+
except Exception:
|
|
389
|
+
mod_log.exception("Errored during module asynchronous callback")
|
|
390
|
+
|
|
370
391
|
def _reset_last_mount(self) -> None:
|
|
371
392
|
self._last_moved_mount = None
|
|
372
393
|
|
|
@@ -422,7 +443,9 @@ class OT3API(
|
|
|
422
443
|
|
|
423
444
|
await api_instance.set_status_bar_enabled(status_bar_enabled)
|
|
424
445
|
module_controls = await AttachedModulesControl.build(
|
|
425
|
-
api_instance,
|
|
446
|
+
api_instance,
|
|
447
|
+
board_revision=backend.board_revision,
|
|
448
|
+
event_callback=api_instance._send_module_notification,
|
|
426
449
|
)
|
|
427
450
|
backend.module_controls = module_controls
|
|
428
451
|
await backend.build_estop_detector()
|
|
@@ -484,7 +507,9 @@ class OT3API(
|
|
|
484
507
|
)
|
|
485
508
|
await api_instance.cache_instruments()
|
|
486
509
|
module_controls = await AttachedModulesControl.build(
|
|
487
|
-
api_instance,
|
|
510
|
+
api_instance,
|
|
511
|
+
board_revision=backend.board_revision,
|
|
512
|
+
event_callback=api_instance._send_module_notification,
|
|
488
513
|
)
|
|
489
514
|
backend.module_controls = module_controls
|
|
490
515
|
await backend.watch(api_instance.loop)
|
|
@@ -627,9 +652,10 @@ class OT3API(
|
|
|
627
652
|
self.is_simulator
|
|
628
653
|
), "Cannot build simulating module from non-simulating hardware control API"
|
|
629
654
|
|
|
630
|
-
return await self._backend.module_controls.
|
|
631
|
-
|
|
632
|
-
|
|
655
|
+
return await self._backend.module_controls.register_simulated_module(
|
|
656
|
+
simulated_usb_port=USBPort(
|
|
657
|
+
name="", port_number=1, port_group=PortGroup.LEFT
|
|
658
|
+
),
|
|
633
659
|
type=modules.ModuleType.from_model(model),
|
|
634
660
|
sim_model=model.value,
|
|
635
661
|
)
|
|
@@ -1499,6 +1525,7 @@ class OT3API(
|
|
|
1499
1525
|
acquire_lock: bool = True,
|
|
1500
1526
|
check_bounds: MotionChecks = MotionChecks.NONE,
|
|
1501
1527
|
expect_stalls: bool = False,
|
|
1528
|
+
delay: Optional[Tuple[List[Axis], float]] = None,
|
|
1502
1529
|
) -> None:
|
|
1503
1530
|
"""Worker function to apply robot motion."""
|
|
1504
1531
|
machine_pos = machine_from_deck(
|
|
@@ -1532,6 +1559,7 @@ class OT3API(
|
|
|
1532
1559
|
machine_pos,
|
|
1533
1560
|
speed or 400.0,
|
|
1534
1561
|
HWStopCondition.stall if expect_stalls else HWStopCondition.none,
|
|
1562
|
+
delay=delay,
|
|
1535
1563
|
)
|
|
1536
1564
|
except Exception:
|
|
1537
1565
|
self._log.exception("Move failed")
|
|
@@ -3057,9 +3085,10 @@ class OT3API(
|
|
|
3057
3085
|
async def aspirate_while_tracking(
|
|
3058
3086
|
self,
|
|
3059
3087
|
mount: Union[top_types.Mount, OT3Mount],
|
|
3060
|
-
|
|
3088
|
+
end_point: top_types.Point,
|
|
3061
3089
|
volume: float,
|
|
3062
|
-
|
|
3090
|
+
rate: float = 1.0,
|
|
3091
|
+
movement_delay: Optional[float] = None,
|
|
3063
3092
|
) -> None:
|
|
3064
3093
|
"""
|
|
3065
3094
|
Aspirate a volume of liquid (in microliters/uL) while moving the z axis synchronously.
|
|
@@ -3067,20 +3096,33 @@ class OT3API(
|
|
|
3067
3096
|
:param mount: A robot mount that the instrument is on.
|
|
3068
3097
|
:param z_distance: The distance the z axis will move during apsiration.
|
|
3069
3098
|
:param volume: The volume of liquid to be aspirated.
|
|
3070
|
-
:param
|
|
3099
|
+
:param rate: The rate multiplier to aspirate with.
|
|
3100
|
+
:param movement_delay: Time to wait after the pipette starts aspirating before x/y/z movement.
|
|
3071
3101
|
"""
|
|
3072
3102
|
realmount = OT3Mount.from_mount(mount)
|
|
3073
3103
|
aspirate_spec = self._pipette_handler.plan_check_aspirate(
|
|
3074
|
-
realmount, volume,
|
|
3104
|
+
realmount, volume, rate
|
|
3075
3105
|
)
|
|
3076
3106
|
if not aspirate_spec:
|
|
3077
3107
|
return
|
|
3108
|
+
end_position = target_position_from_absolute(
|
|
3109
|
+
realmount,
|
|
3110
|
+
end_point,
|
|
3111
|
+
self.critical_point_for,
|
|
3112
|
+
top_types.Point(*self._config.left_mount_offset),
|
|
3113
|
+
top_types.Point(*self._config.right_mount_offset),
|
|
3114
|
+
top_types.Point(*self._config.gripper_mount_offset),
|
|
3115
|
+
)
|
|
3116
|
+
|
|
3078
3117
|
target_pos = target_positions_from_plunger_tracking(
|
|
3079
3118
|
realmount,
|
|
3080
3119
|
aspirate_spec.plunger_distance,
|
|
3081
|
-
|
|
3082
|
-
self._current_position,
|
|
3120
|
+
end_position,
|
|
3083
3121
|
)
|
|
3122
|
+
|
|
3123
|
+
delay: Optional[Tuple[List[Axis], float]] = None
|
|
3124
|
+
if movement_delay is not None:
|
|
3125
|
+
delay = ([Axis.X, Axis.Y, Axis.Z_L, Axis.Z_R], movement_delay)
|
|
3084
3126
|
try:
|
|
3085
3127
|
await self._backend.set_active_current(
|
|
3086
3128
|
{aspirate_spec.axis: aspirate_spec.current}
|
|
@@ -3093,7 +3135,13 @@ class OT3API(
|
|
|
3093
3135
|
target_pos,
|
|
3094
3136
|
speed=aspirate_spec.speed,
|
|
3095
3137
|
home_flagged_axes=False,
|
|
3138
|
+
delay=delay,
|
|
3096
3139
|
)
|
|
3140
|
+
except PipetteOverpressureError:
|
|
3141
|
+
self._log.exception("Aspirate failed with overpressure")
|
|
3142
|
+
# refresh positions during an over pressure here so we know where the gantry stopped.
|
|
3143
|
+
await self.refresh_positions()
|
|
3144
|
+
raise
|
|
3097
3145
|
except Exception:
|
|
3098
3146
|
self._log.exception("Aspirate failed")
|
|
3099
3147
|
aspirate_spec.instr.set_current_volume(0)
|
|
@@ -3104,11 +3152,12 @@ class OT3API(
|
|
|
3104
3152
|
async def dispense_while_tracking(
|
|
3105
3153
|
self,
|
|
3106
3154
|
mount: Union[top_types.Mount, OT3Mount],
|
|
3107
|
-
|
|
3155
|
+
end_point: top_types.Point,
|
|
3108
3156
|
volume: float,
|
|
3109
3157
|
push_out: Optional[float],
|
|
3110
|
-
|
|
3158
|
+
rate: float = 1.0,
|
|
3111
3159
|
is_full_dispense: bool = False,
|
|
3160
|
+
movement_delay: Optional[float] = None,
|
|
3112
3161
|
) -> None:
|
|
3113
3162
|
"""
|
|
3114
3163
|
Dispense a volume of liquid (in microliters/uL) while moving the z axis synchronously.
|
|
@@ -3116,21 +3165,34 @@ class OT3API(
|
|
|
3116
3165
|
:param mount: A robot mount that the instrument is on.
|
|
3117
3166
|
:param z_distance: The distance the z axis will move during dispensing.
|
|
3118
3167
|
:param volume: The volume of liquid to be dispensed.
|
|
3119
|
-
:param
|
|
3168
|
+
:param rate: The rate multiplier to dispense with.
|
|
3169
|
+
:param movement_delay: Time to wait after the pipette starts dispensing before x/y/z movement.
|
|
3120
3170
|
"""
|
|
3121
3171
|
realmount = OT3Mount.from_mount(mount)
|
|
3122
3172
|
dispense_spec = self._pipette_handler.plan_check_dispense(
|
|
3123
|
-
realmount, volume,
|
|
3173
|
+
realmount, volume, rate, push_out, is_full_dispense
|
|
3124
3174
|
)
|
|
3125
3175
|
if not dispense_spec:
|
|
3126
3176
|
return
|
|
3177
|
+
end_position = target_position_from_absolute(
|
|
3178
|
+
realmount,
|
|
3179
|
+
end_point,
|
|
3180
|
+
self.critical_point_for,
|
|
3181
|
+
top_types.Point(*self._config.left_mount_offset),
|
|
3182
|
+
top_types.Point(*self._config.right_mount_offset),
|
|
3183
|
+
top_types.Point(*self._config.gripper_mount_offset),
|
|
3184
|
+
)
|
|
3185
|
+
|
|
3127
3186
|
target_pos = target_positions_from_plunger_tracking(
|
|
3128
3187
|
realmount,
|
|
3129
3188
|
dispense_spec.plunger_distance,
|
|
3130
|
-
|
|
3131
|
-
self._current_position,
|
|
3189
|
+
end_position,
|
|
3132
3190
|
)
|
|
3133
3191
|
|
|
3192
|
+
delay: Optional[Tuple[List[Axis], float]] = None
|
|
3193
|
+
if movement_delay is not None:
|
|
3194
|
+
delay = ([Axis.X, Axis.Y, Axis.Z_L, Axis.Z_R], movement_delay)
|
|
3195
|
+
|
|
3134
3196
|
try:
|
|
3135
3197
|
await self._backend.set_active_current(
|
|
3136
3198
|
{dispense_spec.axis: dispense_spec.current}
|
|
@@ -3143,7 +3205,13 @@ class OT3API(
|
|
|
3143
3205
|
target_pos,
|
|
3144
3206
|
speed=dispense_spec.speed,
|
|
3145
3207
|
home_flagged_axes=False,
|
|
3208
|
+
delay=delay,
|
|
3146
3209
|
)
|
|
3210
|
+
except PipetteOverpressureError:
|
|
3211
|
+
self._log.exception("Aspirate failed with overpressure")
|
|
3212
|
+
# refresh positions during an over pressure here so we know where the gantry stopped.
|
|
3213
|
+
await self.refresh_positions()
|
|
3214
|
+
raise
|
|
3147
3215
|
except Exception:
|
|
3148
3216
|
self._log.exception("dispense failed")
|
|
3149
3217
|
dispense_spec.instr.set_current_volume(0)
|
|
@@ -5,6 +5,7 @@ from abc import ABC, abstractmethod
|
|
|
5
5
|
from typing import AsyncGenerator, List, Optional
|
|
6
6
|
from opentrons.hardware_control.modules.errors import AbsorbanceReaderDisconnectedError
|
|
7
7
|
from opentrons_shared_data.errors.exceptions import ModuleCommunicationError
|
|
8
|
+
from opentrons.drivers.asyncio.communication.errors import SerialException
|
|
8
9
|
|
|
9
10
|
|
|
10
11
|
log = logging.getLogger(__name__)
|
|
@@ -88,6 +89,18 @@ class Poller:
|
|
|
88
89
|
except asyncio.InvalidStateError:
|
|
89
90
|
log.warning("Poller waiter was already cancelled")
|
|
90
91
|
|
|
92
|
+
def _error_callback(self, exc: Exception) -> None:
|
|
93
|
+
try:
|
|
94
|
+
self._reader.on_error(exc)
|
|
95
|
+
except Exception:
|
|
96
|
+
log.exception("Exception in reader callback")
|
|
97
|
+
|
|
98
|
+
def _complete_all(
|
|
99
|
+
self, exc: Exception | None, previous: List["asyncio.Future[None]"]
|
|
100
|
+
) -> None:
|
|
101
|
+
for waiter in previous:
|
|
102
|
+
Poller._set_waiter_complete(waiter, exc)
|
|
103
|
+
|
|
91
104
|
async def _poll_once(self) -> None:
|
|
92
105
|
"""Trigger a single read, notifying listeners of success or error."""
|
|
93
106
|
previous_waiters = self._poll_waiters
|
|
@@ -99,14 +112,15 @@ class Poller:
|
|
|
99
112
|
except asyncio.CancelledError:
|
|
100
113
|
raise
|
|
101
114
|
except AbsorbanceReaderDisconnectedError as e:
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
115
|
+
self._error_callback(e)
|
|
116
|
+
self._complete_all(e, previous_waiters)
|
|
117
|
+
except SerialException as se:
|
|
118
|
+
log.error(f"Polling gcode error: {se}")
|
|
119
|
+
self._error_callback(se)
|
|
120
|
+
self._complete_all(se, previous_waiters)
|
|
105
121
|
except Exception as e:
|
|
106
122
|
log.exception("Polling exception")
|
|
107
|
-
self.
|
|
108
|
-
|
|
109
|
-
Poller._set_waiter_complete(waiter, e)
|
|
123
|
+
self._error_callback(e)
|
|
124
|
+
self._complete_all(e, previous_waiters)
|
|
110
125
|
else:
|
|
111
|
-
|
|
112
|
-
Poller._set_waiter_complete(waiter)
|
|
126
|
+
self._complete_all(None, previous_waiters)
|
|
@@ -125,9 +125,10 @@ class LiquidHandler(
|
|
|
125
125
|
async def aspirate_while_tracking(
|
|
126
126
|
self,
|
|
127
127
|
mount: MountArgType,
|
|
128
|
-
|
|
128
|
+
end_point: Point,
|
|
129
129
|
volume: float,
|
|
130
130
|
flow_rate: float = 1.0,
|
|
131
|
+
movement_delay: Optional[float] = None,
|
|
131
132
|
) -> None:
|
|
132
133
|
"""
|
|
133
134
|
Aspirate a volume of liquid (in microliters/uL) while moving the z axis synchronously.
|
|
@@ -136,6 +137,7 @@ class LiquidHandler(
|
|
|
136
137
|
:param z_distance: The distance the z axis will move during apsiration.
|
|
137
138
|
:param volume: The volume of liquid to be aspirated.
|
|
138
139
|
:param flow_rate: The flow rate to aspirate with.
|
|
140
|
+
:param movement_delay: Time to wait after the pipette starts aspirating before x/y/z movement.
|
|
139
141
|
"""
|
|
140
142
|
...
|
|
141
143
|
|
|
@@ -164,11 +166,12 @@ class LiquidHandler(
|
|
|
164
166
|
async def dispense_while_tracking(
|
|
165
167
|
self,
|
|
166
168
|
mount: MountArgType,
|
|
167
|
-
|
|
169
|
+
end_point: Point,
|
|
168
170
|
volume: float,
|
|
169
171
|
push_out: Optional[float],
|
|
170
172
|
flow_rate: float = 1.0,
|
|
171
173
|
is_full_dispense: bool = False,
|
|
174
|
+
movement_delay: Optional[float] = None,
|
|
172
175
|
) -> None:
|
|
173
176
|
"""
|
|
174
177
|
Dispense a volume of liquid (in microliters/uL) while moving the z axis synchronously.
|
|
@@ -177,6 +180,7 @@ class LiquidHandler(
|
|
|
177
180
|
:param z_distance: The distance the z axis will move during dispensing.
|
|
178
181
|
:param volume: The volume of liquid to be dispensed.
|
|
179
182
|
:param flow_rate: The flow rate to dispense with.
|
|
183
|
+
:param movement_delay: Time to wait after the pipette starts dispensing before x/y/z movement.
|
|
180
184
|
"""
|
|
181
185
|
...
|
|
182
186
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
"""Module Firmware update script."""
|
|
2
|
+
|
|
2
3
|
import argparse
|
|
3
4
|
import asyncio
|
|
4
5
|
from glob import glob
|
|
@@ -14,6 +15,7 @@ from opentrons.hardware_control import modules
|
|
|
14
15
|
from opentrons.hardware_control.modules.mod_abc import AbstractModule
|
|
15
16
|
from opentrons.hardware_control.modules.update import update_firmware
|
|
16
17
|
from opentrons.hardware_control.types import BoardRevision
|
|
18
|
+
from opentrons.hardware_control.execution_manager import ExecutionManager
|
|
17
19
|
|
|
18
20
|
|
|
19
21
|
# Constants for checking if module is back online
|
|
@@ -84,6 +86,9 @@ async def build_module(
|
|
|
84
86
|
port=port,
|
|
85
87
|
usb_port=mod.usb_port,
|
|
86
88
|
type=modules.MODULE_TYPE_BY_NAME[mod.name],
|
|
89
|
+
execution_manager=ExecutionManager(),
|
|
90
|
+
disconnected_callback=lambda *args: None,
|
|
91
|
+
error_callback=lambda *args: None,
|
|
87
92
|
simulating=False,
|
|
88
93
|
hw_control_loop=loop,
|
|
89
94
|
)
|
|
@@ -2,12 +2,26 @@ from asyncio import Queue
|
|
|
2
2
|
import enum
|
|
3
3
|
import logging
|
|
4
4
|
from dataclasses import dataclass
|
|
5
|
-
from typing import
|
|
5
|
+
from typing import (
|
|
6
|
+
cast,
|
|
7
|
+
Tuple,
|
|
8
|
+
Union,
|
|
9
|
+
List,
|
|
10
|
+
Callable,
|
|
11
|
+
Dict,
|
|
12
|
+
TypeVar,
|
|
13
|
+
Type,
|
|
14
|
+
TYPE_CHECKING,
|
|
15
|
+
)
|
|
6
16
|
from typing_extensions import Literal
|
|
7
17
|
from opentrons import types as top_types
|
|
8
18
|
from opentrons_shared_data.pipette.types import PipetteChannelType
|
|
19
|
+
from opentrons_shared_data.errors.exceptions import EnumeratedError
|
|
9
20
|
from opentrons.config import feature_flags
|
|
10
21
|
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from .modules.types import ModuleModel
|
|
24
|
+
|
|
11
25
|
MODULE_LOG = logging.getLogger(__name__)
|
|
12
26
|
|
|
13
27
|
|
|
@@ -384,6 +398,8 @@ class HardwareEventType(enum.Enum):
|
|
|
384
398
|
DOOR_SWITCH_CHANGE = enum.auto()
|
|
385
399
|
ERROR_MESSAGE = enum.auto()
|
|
386
400
|
ESTOP_CHANGE = enum.auto()
|
|
401
|
+
ASYNCHRONOUS_MODULE_ERROR = enum.auto()
|
|
402
|
+
MODULE_DISCONNECTED = enum.auto()
|
|
387
403
|
|
|
388
404
|
|
|
389
405
|
@dataclass
|
|
@@ -428,10 +444,35 @@ class ErrorMessageNotification:
|
|
|
428
444
|
event: Literal[HardwareEventType.ERROR_MESSAGE] = HardwareEventType.ERROR_MESSAGE
|
|
429
445
|
|
|
430
446
|
|
|
447
|
+
@dataclass(frozen=True)
|
|
448
|
+
class AsynchronousModuleErrorNotification:
|
|
449
|
+
exception: EnumeratedError
|
|
450
|
+
module_serial: str | None
|
|
451
|
+
module_model: "ModuleModel"
|
|
452
|
+
port: str
|
|
453
|
+
event: Literal[
|
|
454
|
+
HardwareEventType.ASYNCHRONOUS_MODULE_ERROR
|
|
455
|
+
] = HardwareEventType.ASYNCHRONOUS_MODULE_ERROR
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
@dataclass(frozen=True)
|
|
459
|
+
class ModuleDisconnectedNotification:
|
|
460
|
+
module_serial: str | None
|
|
461
|
+
module_model: "ModuleModel"
|
|
462
|
+
port: str
|
|
463
|
+
event: Literal[
|
|
464
|
+
HardwareEventType.MODULE_DISCONNECTED
|
|
465
|
+
] = HardwareEventType.MODULE_DISCONNECTED
|
|
466
|
+
|
|
467
|
+
|
|
431
468
|
# new event types get new dataclasses
|
|
432
469
|
# when we add more event types we add them here
|
|
433
470
|
HardwareEvent = Union[
|
|
434
|
-
DoorStateNotification,
|
|
471
|
+
DoorStateNotification,
|
|
472
|
+
ErrorMessageNotification,
|
|
473
|
+
EstopStateNotification,
|
|
474
|
+
AsynchronousModuleErrorNotification,
|
|
475
|
+
ModuleDisconnectedNotification,
|
|
435
476
|
]
|
|
436
477
|
|
|
437
478
|
HardwareEventHandler = Callable[[HardwareEvent], None]
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
-
from typing import TYPE_CHECKING, List, Sequence, Union, overload
|
|
2
|
+
from typing import TYPE_CHECKING, List, Sequence, Union, overload, Optional
|
|
3
3
|
|
|
4
4
|
|
|
5
5
|
from .helpers import (
|
|
@@ -31,10 +31,21 @@ def aspirate(
|
|
|
31
31
|
location: Location,
|
|
32
32
|
flow_rate: float,
|
|
33
33
|
rate: float,
|
|
34
|
+
end_location: Optional[Location],
|
|
34
35
|
) -> command_types.AspirateCommand:
|
|
35
36
|
location_text = stringify_location(location)
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
end_location_text = (
|
|
38
|
+
f" while moving to {stringify_location(end_location)}"
|
|
39
|
+
if end_location is not None
|
|
40
|
+
else ""
|
|
41
|
+
)
|
|
42
|
+
template = "Aspirating {volume} uL from {location} at {flow} uL/sec{end}"
|
|
43
|
+
text = template.format(
|
|
44
|
+
volume=float(volume),
|
|
45
|
+
location=location_text,
|
|
46
|
+
flow=flow_rate,
|
|
47
|
+
end=end_location_text,
|
|
48
|
+
)
|
|
38
49
|
|
|
39
50
|
return {
|
|
40
51
|
"name": command_types.ASPIRATE,
|
|
@@ -44,6 +55,7 @@ def aspirate(
|
|
|
44
55
|
"location": location,
|
|
45
56
|
"rate": rate,
|
|
46
57
|
"text": text,
|
|
58
|
+
"end_location": end_location,
|
|
47
59
|
},
|
|
48
60
|
}
|
|
49
61
|
|
|
@@ -54,10 +66,21 @@ def dispense(
|
|
|
54
66
|
location: Location,
|
|
55
67
|
flow_rate: float,
|
|
56
68
|
rate: float,
|
|
69
|
+
end_location: Optional[Location],
|
|
57
70
|
) -> command_types.DispenseCommand:
|
|
58
71
|
location_text = stringify_location(location)
|
|
59
|
-
|
|
60
|
-
|
|
72
|
+
end_location_text = (
|
|
73
|
+
f" while moving to {stringify_location(end_location)}"
|
|
74
|
+
if end_location is not None
|
|
75
|
+
else ""
|
|
76
|
+
)
|
|
77
|
+
template = "Dispensing {volume} uL into {location} at {flow} uL/sec{end}"
|
|
78
|
+
text = template.format(
|
|
79
|
+
volume=float(volume),
|
|
80
|
+
location=location_text,
|
|
81
|
+
flow=flow_rate,
|
|
82
|
+
end=end_location_text,
|
|
83
|
+
)
|
|
61
84
|
|
|
62
85
|
return {
|
|
63
86
|
"name": command_types.DISPENSE,
|
|
@@ -67,6 +90,7 @@ def dispense(
|
|
|
67
90
|
"location": location,
|
|
68
91
|
"rate": rate,
|
|
69
92
|
"text": text,
|
|
93
|
+
"end_location": end_location,
|
|
70
94
|
},
|
|
71
95
|
}
|
|
72
96
|
|
|
@@ -208,6 +232,35 @@ def mix(
|
|
|
208
232
|
}
|
|
209
233
|
|
|
210
234
|
|
|
235
|
+
def dynamic_mix(
|
|
236
|
+
instrument: InstrumentContext,
|
|
237
|
+
repetitions: int,
|
|
238
|
+
volume: float,
|
|
239
|
+
aspirate_start_location: Location,
|
|
240
|
+
aspirate_end_location: Union[Location, None],
|
|
241
|
+
dispense_start_location: Location,
|
|
242
|
+
dispense_end_location: Union[Location, None],
|
|
243
|
+
movement_delay: float,
|
|
244
|
+
) -> command_types.DynamicMixCommand:
|
|
245
|
+
text = "Dynamically mixing {repetitions} times with a volume of {volume} ul".format(
|
|
246
|
+
repetitions=repetitions, volume=float(volume)
|
|
247
|
+
)
|
|
248
|
+
return {
|
|
249
|
+
"name": command_types.MIX,
|
|
250
|
+
"payload": {
|
|
251
|
+
"instrument": instrument,
|
|
252
|
+
"aspirate_start_location": aspirate_start_location,
|
|
253
|
+
"aspirate_end_location": aspirate_end_location,
|
|
254
|
+
"dispense_start_location": dispense_start_location,
|
|
255
|
+
"dispense_end_location": dispense_end_location,
|
|
256
|
+
"volume": volume,
|
|
257
|
+
"repetitions": repetitions,
|
|
258
|
+
"text": text,
|
|
259
|
+
"movement_delay": movement_delay,
|
|
260
|
+
},
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
|
|
211
264
|
def blow_out(
|
|
212
265
|
instrument: InstrumentContext, location: Location
|
|
213
266
|
) -> command_types.BlowOutCommand:
|
|
@@ -89,6 +89,24 @@ def thermocycler_set_block_temp(
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
|
|
92
|
+
def thermocycler_start_set_block_temp(
|
|
93
|
+
temperature: float,
|
|
94
|
+
) -> command_types.ThermocyclerStartSetBlockTempCommand:
|
|
95
|
+
temp = round(float(temperature), utils.TC_GCODE_ROUNDING_PRECISION)
|
|
96
|
+
text = f"Starting to set Thermocycler well block temperature to {temp} °C"
|
|
97
|
+
# TODO: BC 2019-09-05 this time resolving logic is partially duplicated
|
|
98
|
+
# in the thermocycler api class definition, with this command logger
|
|
99
|
+
# implementation, there isn't a great way to avoid this, but it should
|
|
100
|
+
# be consolidated as soon as an alternative to the publisher is settled on.
|
|
101
|
+
return {
|
|
102
|
+
"name": command_types.THERMOCYCLER_START_SET_BLOCK_TEMP,
|
|
103
|
+
"payload": {
|
|
104
|
+
"temperature": temperature,
|
|
105
|
+
"text": text,
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
92
110
|
def thermocycler_execute_profile(
|
|
93
111
|
steps: List[ThermocyclerStep], repetitions: int
|
|
94
112
|
) -> command_types.ThermocyclerExecuteProfileCommand:
|
|
@@ -102,6 +120,19 @@ def thermocycler_execute_profile(
|
|
|
102
120
|
}
|
|
103
121
|
|
|
104
122
|
|
|
123
|
+
def thermocycler_start_execute_profile(
|
|
124
|
+
steps: List[ThermocyclerStep], repetitions: int
|
|
125
|
+
) -> command_types.ThermocyclerStartExecuteProfileCommand:
|
|
126
|
+
text = (
|
|
127
|
+
f"In the background, thermocycler starting to run {repetitions} repetitions "
|
|
128
|
+
f" of cycle composed of the following steps: {steps}"
|
|
129
|
+
)
|
|
130
|
+
return {
|
|
131
|
+
"name": command_types.THERMOCYCLER_START_EXECUTE_PROFILE,
|
|
132
|
+
"payload": {"text": text, "steps": steps},
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
105
136
|
def thermocycler_wait_for_hold() -> command_types.ThermocyclerWaitForHoldCommand:
|
|
106
137
|
text = "Waiting for hold time duration"
|
|
107
138
|
return {"name": command_types.THERMOCYCLER_WAIT_FOR_HOLD, "payload": {"text": text}}
|
|
@@ -120,6 +151,17 @@ def thermocycler_set_lid_temperature(
|
|
|
120
151
|
return {"name": command_types.THERMOCYCLER_SET_LID_TEMP, "payload": {"text": text}}
|
|
121
152
|
|
|
122
153
|
|
|
154
|
+
def thermocycler_start_set_lid_temperature(
|
|
155
|
+
temperature: float,
|
|
156
|
+
) -> command_types.ThermocyclerStartSetLidTempCommand:
|
|
157
|
+
temp = round(float(temperature), utils.TC_GCODE_ROUNDING_PRECISION)
|
|
158
|
+
text = f"Starting to set Thermocycler lid temperature to {temp} °C"
|
|
159
|
+
return {
|
|
160
|
+
"name": command_types.THERMOCYCLER_START_SET_LID_TEMP,
|
|
161
|
+
"payload": {"text": text},
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
|
|
123
165
|
def thermocycler_deactivate_lid() -> command_types.ThermocyclerDeactivateLidCommand:
|
|
124
166
|
text = "Deactivating Thermocycler lid heating"
|
|
125
167
|
return {
|
|
@@ -183,6 +225,16 @@ def heater_shaker_set_and_wait_for_shake_speed(
|
|
|
183
225
|
}
|
|
184
226
|
|
|
185
227
|
|
|
228
|
+
def heater_shaker_set_shake_speed(
|
|
229
|
+
rpm: int,
|
|
230
|
+
) -> command_types.HeaterShakerSetShakeSpeedCommand:
|
|
231
|
+
text = f"Setting Heater-Shaker to Shake at {rpm} RPM"
|
|
232
|
+
return {
|
|
233
|
+
"name": command_types.HEATER_SHAKER_SET_SHAKE_SPEED,
|
|
234
|
+
"payload": {"text": text},
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
|
|
186
238
|
def heater_shaker_open_labware_latch() -> command_types.HeaterShakerOpenLabwareLatchCommand:
|
|
187
239
|
text = "Unlatching labware on Heater-Shaker"
|
|
188
240
|
return {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from datetime import timedelta
|
|
2
|
-
from typing import Optional
|
|
2
|
+
from typing import Optional, Tuple
|
|
3
3
|
from . import types as command_types
|
|
4
|
+
from opentrons.protocol_api.tasks import Task
|
|
4
5
|
|
|
5
6
|
|
|
6
7
|
def comment(msg: str) -> command_types.CommentCommand:
|
|
@@ -52,3 +53,54 @@ def move_labware(text: str) -> command_types.MoveLabwareCommand:
|
|
|
52
53
|
"name": command_types.MOVE_LABWARE,
|
|
53
54
|
"payload": {"text": text},
|
|
54
55
|
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def capture_image(
|
|
59
|
+
resolution: Optional[Tuple[int, int]],
|
|
60
|
+
zoom: Optional[float],
|
|
61
|
+
contrast: Optional[float],
|
|
62
|
+
brightness: Optional[float],
|
|
63
|
+
saturation: Optional[float],
|
|
64
|
+
) -> command_types.CaptureImageCommand:
|
|
65
|
+
text = "Capturing an image"
|
|
66
|
+
if resolution:
|
|
67
|
+
text += f" with resolution {resolution[0]}x{resolution[1]}"
|
|
68
|
+
if zoom:
|
|
69
|
+
text += f" zoom of {zoom}X"
|
|
70
|
+
if contrast:
|
|
71
|
+
text += f" contrast of {contrast}%"
|
|
72
|
+
if brightness:
|
|
73
|
+
text += f" brightness of {brightness}%"
|
|
74
|
+
if saturation:
|
|
75
|
+
text += f" saturation of {saturation}%"
|
|
76
|
+
text += "."
|
|
77
|
+
return {
|
|
78
|
+
"name": command_types.CAPTURE_IMAGE,
|
|
79
|
+
"payload": {
|
|
80
|
+
"text": text,
|
|
81
|
+
"resolution": resolution,
|
|
82
|
+
"zoom": zoom,
|
|
83
|
+
"contrast": contrast,
|
|
84
|
+
"brightness": brightness,
|
|
85
|
+
"saturation": saturation,
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def wait_for_tasks(tasks: list[Task]) -> command_types.WaitForTasksCommand:
|
|
91
|
+
task_ids = [task.created_at.strftime("%Y-%m-%d %H:%M:%S") for task in tasks]
|
|
92
|
+
msg = f"Waiting for tasks that started at: {task_ids}."
|
|
93
|
+
return {
|
|
94
|
+
"name": command_types.WAIT_FOR_TASKS,
|
|
95
|
+
"payload": {"text": msg},
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def create_timer(seconds: float) -> command_types.CreateTimerCommand:
|
|
100
|
+
return {
|
|
101
|
+
"name": command_types.CREATE_TIMER,
|
|
102
|
+
"payload": {
|
|
103
|
+
"text": f"Creating background timer for {seconds} seconds.",
|
|
104
|
+
"time": seconds,
|
|
105
|
+
},
|
|
106
|
+
}
|