diff --git a/api/src/opentrons/protocol_api/contexts.py b/api/src/opentrons/protocol_api/contexts.py index 16bf1fbe194..7869bd1f0e4 100644 --- a/api/src/opentrons/protocol_api/contexts.py +++ b/api/src/opentrons/protocol_api/contexts.py @@ -1231,18 +1231,17 @@ def _execute_transfer(self, plan: transfers.TransferPlan): def delay(self): return self._ctx.delay() - def move_to(self, location: types.Location, z_safety: float = None + def move_to(self, location: types.Location, force_direct: bool = False, + minimum_z_height: float = None ) -> 'InstrumentContext': """ Move the instrument. :param location: The location to move to. :type location: :py:class:`.types.Location` - :param z_safety: An optional height to retract the pipette to before - moving. If not specified, it will be generated based - on the labware from which and to which the pipette is - moving; if it is 0, the pipette will move directly; - and if it is non-zero, the pipette will rise to the - z_safety point before moving in x and y. + :param force_direct: If set to true, move directly to destination + without arc motion. + :param minimum_z_height: When specified, this Z margin is able to raise + (but never lower) the mid-arc height. """ if self._ctx.location_cache: from_lw = self._ctx.location_cache.labware @@ -1258,7 +1257,8 @@ def move_to(self, location: types.Location, z_safety: float = None from_lw) moves = geometry.plan_moves(from_loc, location, self._ctx.deck, - z_margin_override=z_safety) + force_direct=force_direct, + minimum_z_height=minimum_z_height) self._log.debug("move_to: {}->{} via:\n\t{}" .format(from_loc, location, moves)) try: diff --git a/api/src/opentrons/protocol_api/execute.py b/api/src/opentrons/protocol_api/execute.py index 84b20ca140d..f88efd3fe01 100644 --- a/api/src/opentrons/protocol_api/execute.py +++ b/api/src/opentrons/protocol_api/execute.py @@ -312,6 +312,24 @@ def dispatch_json(context: ProtocolContext, # noqa(C901) well = _get_well(labware, params) offset = default_values.get('touch-tip-mm-from-top', -1) pipette.touch_tip(location, v_offset=offset) # type: ignore + + elif command_type == 'move-to-slot': + slot = params.get('slot') + if slot not in [str(s+1) for s in range(12)]: + raise ValueError('Invalid "slot" for "move-to-slot": {}' + .format(slot)) + slot_obj = context.deck.position_for(slot) + + offset = params.get('offset', {}) + offsetPoint = Point( + offset.get('x', 0), + offset.get('y', 0), + offset.get('z', 0)) + + pipette.move_to( # type: ignore + slot_obj.move(offsetPoint), + force_direct=params.get('force-direct'), + minimum_z_height=params.get('minimum-z-height')) else: MODULE_LOG.warning("Bad command type {}".format(command_type)) diff --git a/api/src/opentrons/protocol_api/geometry.py b/api/src/opentrons/protocol_api/geometry.py index 2bc567dbbd0..d5840e56f41 100644 --- a/api/src/opentrons/protocol_api/geometry.py +++ b/api/src/opentrons/protocol_api/geometry.py @@ -21,7 +21,8 @@ def plan_moves( deck: 'Deck', well_z_margin: float = 5.0, lw_z_margin: float = 20.0, - z_margin_override: float = None)\ + force_direct: bool = False, + minimum_z_height: float = None)\ -> List[Tuple[types.Point, Optional[CriticalPoint]]]: """ Plan moves between one :py:class:`.Location` and another. @@ -40,11 +41,16 @@ def plan_moves( :param float lw_z_margin: How much extra Z margin to raise the cp by over the bare minimum to clear different pieces of labware. Default: 20mm + :param force_direct: If True, ignore any Z margins force a direct move + :param minimum_z_height: When specified, this Z margin is able to raise + (but never lower) the mid-arc height. :returns: A list of tuples of :py:class:`.Point` and critical point overrides to move through. """ + assert minimum_z_height is None or minimum_z_height >= 0.0 + def _split_loc_labware( loc: types.Location) -> Tuple[Optional[Labware], Optional[Well]]: if isinstance(loc.labware, Labware): @@ -65,35 +71,41 @@ def _split_loc_labware( dest_cp_override = CriticalPoint.XY_CENTER if to_center else None origin_cp_override = CriticalPoint.XY_CENTER if from_center else None + is_same_location = ((to_lw and to_lw == from_lw) + and (to_well and to_well == from_well)) + if (force_direct or (is_same_location and not + (minimum_z_height or 0) > 0)): + # If we’re going direct, we can assume we’re already in the correct + # cp so we can use the override without prep + return [(to_point, dest_cp_override)] + + # Generate arc moves + + # Find the safe z heights based on the destination and origin labware/well if to_lw and to_lw == from_lw: - # Two valid labwares. We’ll either raise to clear a well or go direct - if z_margin_override == 0.0 or (to_well and to_well == from_well): - # If we’re going direct, we can assume we’re already in the correct - # cp so we can use the override without prep - return [(to_point, dest_cp_override)] + # If we know the labwares we’re moving from and to, we can calculate + # a safe z based on their heights + if to_well: + to_safety = to_well.top().point.z + well_z_margin else: - if to_well: - to_safety = to_well.top().point.z + well_z_margin - else: - to_safety = to_lw.highest_z + well_z_margin - if from_well: - from_safety = from_well.top().point.z + well_z_margin - else: - from_safety = from_lw.highest_z + well_z_margin - - safe = max_many( - to_point.z, - from_point.z, - to_safety, - from_safety) + to_safety = to_lw.highest_z + well_z_margin + if from_well: + from_safety = from_well.top().point.z + well_z_margin + else: + from_safety = from_lw.highest_z + well_z_margin else: - # For now, the only fallback is to clear all known labware - safe = max_many(to_point.z, - from_point.z, - deck.highest_z + lw_z_margin) + # One of our labwares is invalid so we have to just go above + # deck.highest_z since we don’t know where we are + to_safety = deck.highest_z + lw_z_margin + from_safety = 0.0 # (ignore since it’s in a max()) + + safe = max_many( + to_point.z, + from_point.z, + to_safety, + from_safety, + minimum_z_height or 0) - if z_margin_override is not None and z_margin_override >= 0.0: - safe = z_margin_override # We should use the origin’s cp for the first move since it should # move only in z and the destination’s cp subsequently return [(from_point._replace(z=safe), origin_cp_override), diff --git a/api/src/opentrons/protocols/__init__.py b/api/src/opentrons/protocols/__init__.py index 1f8f3a4f825..58ac67c7174 100644 --- a/api/src/opentrons/protocols/__init__.py +++ b/api/src/opentrons/protocols/__init__.py @@ -1,3 +1,4 @@ +from numpy import add import time from itertools import chain from opentrons import instruments, labware, robot @@ -34,6 +35,7 @@ def load_labware(protocol_data): loaded_labware = {} for labware_id, props in data.items(): slot = props.get('slot') + # TODO: Ian 2019-03-19 throw error if slot is number, only allow string model = props.get('model') display_name = props.get('display-name') @@ -210,6 +212,27 @@ def dispatch_commands(protocol_data, loaded_pipettes, loaded_labware): # noqa: pipette.touch_tip(well_object, v_offset=offset_from_top) + elif command_type == 'move-to-slot': + slot = params.get('slot') + if slot not in [str(s+1) for s in range(12)]: + raise ValueError('"move-to-slot" requires a valid slot, got {}' + .format(slot)) + x_offset = params.get('offset', {}).get('x', 0) + y_offset = params.get('offset', {}).get('y', 0) + z_offset = params.get('offset', {}).get('z', 0) + slot_placeable = robot.deck[slot] + slot_offset = (x_offset, y_offset, z_offset) + + strategy = 'direct' if params.get('force-direct') else None + + # NOTE: Robot.move_to subtracts the offset from Slot.top()[1], + # so in order not to translate our desired offset, + # we have to compensate by adding it here :/ + pipette.move_to( + (slot_placeable, + add(slot_offset, tuple(slot_placeable.top()[1]))), + strategy=strategy) + def execute_protocol(protocol): loaded_pipettes = load_pipettes(protocol) diff --git a/api/tests/opentrons/protocol_api/test_context.py b/api/tests/opentrons/protocol_api/test_context.py index 0f07fb89f37..6909b8553e3 100644 --- a/api/tests/opentrons/protocol_api/test_context.py +++ b/api/tests/opentrons/protocol_api/test_context.py @@ -64,7 +64,8 @@ def test_location_cache(loop, monkeypatch, load_my_labware): def fake_plan_move(from_loc, to_loc, deck, well_z_margin=None, lw_z_margin=None, - z_margin_override=None): + force_direct=False, + minimum_z_height=None): nonlocal test_args test_args = (from_loc, to_loc, deck, well_z_margin, lw_z_margin) return [(Point(0, 1, 10), None), diff --git a/api/tests/opentrons/protocol_api/test_geometry.py b/api/tests/opentrons/protocol_api/test_geometry.py index ccfbf12f969..8d94cba3346 100644 --- a/api/tests/opentrons/protocol_api/test_geometry.py +++ b/api/tests/opentrons/protocol_api/test_geometry.py @@ -49,7 +49,7 @@ def test_highest_z(): assert deck.highest_z == mod.highest_z -def check_arc_basic(arc, from_loc, to_loc, use_z_margin_override=False): +def check_arc_basic(arc, from_loc, to_loc): """ Check the tests that should always be true for different-well moves - we should always go only up, then only xy, then only down - we should have three moves @@ -59,9 +59,8 @@ def check_arc_basic(arc, from_loc, to_loc, use_z_margin_override=False): assert arc[0][0].z == arc[1][0].z assert arc[1][0]._replace(z=0) == to_loc.point._replace(z=0) assert arc[2][0] == to_loc.point - if not use_z_margin_override: - assert arc[0][0].z >= from_loc.point.z - assert arc[1][0].z >= to_loc.point.z + assert arc[0][0].z >= from_loc.point.z + assert arc[1][0].z >= to_loc.point.z def test_direct_movs(): @@ -98,6 +97,25 @@ def test_basic_arc(): assert different_lw[0][0].z == deck.highest_z + 15.0 +def test_force_direct(): + deck = Deck() + lw1 = labware.load(labware_name, deck.position_for(1)) + lw2 = labware.load(labware_name, deck.position_for(2)) + # same-labware moves should move direct + same_lw = plan_moves(lw1.wells()[0].top(), + lw1.wells()[8].bottom(), + deck, + 7.0, 15.0, force_direct=True) + assert same_lw == [(lw1.wells()[8].bottom().point, None)] + + # different-labware moves should move direct + different_lw = plan_moves(lw1.wells()[0].top(), + lw2.wells()[0].bottom(), + deck, + 7.0, 15.0, force_direct=True) + assert different_lw == [(lw2.wells()[0].bottom().point, None)] + + def test_no_labware_loc(): labware_def = labware.load_definition_by_name(labware_name) @@ -149,50 +167,44 @@ def test_arc_tall_point(): check_arc_basic(from_tall_lw, no_well, lw1.wells()[4].bottom()) -def test_arc_lower_z_margin_override(): +def test_arc_lower_minimum_z_height(): deck = Deck() lw1 = labware.load(labware_name, deck.position_for(1)) tall_z = 100 - z_margin_override = 42 + minimum_z_height = 42 old_top = lw1.wells()[0].top() tall_point = old_top.point._replace(z=tall_z) tall_top = old_top._replace(point=tall_point) to_tall = plan_moves( - lw1.wells()[2].top(), tall_top, deck, 7.0, 15.0, z_margin_override) - check_arc_basic(to_tall, lw1.wells()[2].top(), tall_top, True) - assert to_tall[0][0].z == z_margin_override + lw1.wells()[2].top(), tall_top, deck, 7.0, 15.0, False, + minimum_z_height=minimum_z_height) + check_arc_basic(to_tall, lw1.wells()[2].top(), tall_top) + assert to_tall[0][0].z == tall_z from_tall = plan_moves( - tall_top, lw1.wells()[3].top(), deck, 7.0, 15.0, z_margin_override) - check_arc_basic(from_tall, tall_top, lw1.wells()[3].top(), True) - assert from_tall[0][0].z == z_margin_override + tall_top, lw1.wells()[3].top(), deck, 7.0, 15.0, + minimum_z_height=minimum_z_height) + check_arc_basic(from_tall, tall_top, lw1.wells()[3].top()) + assert from_tall[0][0].z == tall_z no_well = tall_top._replace(labware=lw1) from_tall_lw = plan_moves(no_well, lw1.wells()[4].bottom(), deck, 7.0, 15.0) - check_arc_basic(from_tall_lw, no_well, lw1.wells()[4].bottom(), True) + check_arc_basic(from_tall_lw, no_well, lw1.wells()[4].bottom()) -def test_arc_zero_z_margin_override(): +def test_direct_minimum_z_height(): deck = Deck() lw1 = labware.load(labware_name, deck.position_for(1)) - tall_z = 100 - z_margin_override = 0 - old_top = lw1.wells()[0].top() - tall_point = old_top.point._replace(z=tall_z) - tall_top = old_top._replace(point=tall_point) - to_tall = plan_moves( - lw1.wells()[2].top(), tall_top, deck, 7.0, 15.0, z_margin_override) - assert to_tall == [(tall_top.point, None)] - - from_tall = plan_moves( - tall_top, lw1.wells()[3].top(), deck, 7.0, 15.0, z_margin_override) - assert from_tall == [(lw1.wells()[3].top().point, None)] - - no_well = tall_top._replace(labware=lw1) - from_tall_lw = plan_moves(no_well, lw1.wells()[4].bottom(), deck, - 7.0, 15.0, z_margin_override) - assert from_tall_lw == [(lw1.wells()[4].bottom().point, None)] + from_loc = lw1.wells()[0].bottom().move(Point(x=-2)) + to_loc = lw1.wells()[0].bottom().move(Point(x=2)) + zmo = 150 + # This would normally be a direct move since it’s inside the same well, + # but we want to check that we override it into an arc + moves = plan_moves(from_loc, to_loc, deck, minimum_z_height=zmo) + assert len(moves) == 3 + assert moves[0][0].z == zmo # equals zmo b/c 150 is max of all safe z's + check_arc_basic(moves, from_loc, to_loc) def test_direct_cp(): @@ -204,7 +216,6 @@ def test_direct_cp(): from_nothing = plan_moves(Location(Point(50, 50, 50), None), trough.wells()[0].top(), deck) - # import pdb; pdb.set_trace() check_arc_basic(from_nothing, Location(Point(50, 50, 50), None), trough.wells()[0].top()) assert from_nothing[0][1] is None diff --git a/shared-data/protocol-json-schema/protocol-schema.json b/shared-data/protocol-json-schema/protocolSchemaV1.json similarity index 100% rename from shared-data/protocol-json-schema/protocol-schema.json rename to shared-data/protocol-json-schema/protocolSchemaV1.json diff --git a/shared-data/protocol-json-schema/protocolSchemaV2.json b/shared-data/protocol-json-schema/protocolSchemaV2.json new file mode 100644 index 00000000000..6900386ed2d --- /dev/null +++ b/shared-data/protocol-json-schema/protocolSchemaV2.json @@ -0,0 +1,416 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + + "definitions": { + "pipetteName": { + "description": "Name of a pipette. Does not contain info about specific model/version. Should match keys in pipetteNameSpecs.json", + "type": "string", + "enum": [ + "p10_single", + "p10_multi", + "p50_single", + "p50_multi", + "p300_single", + "p300_multi", + "p1000_single", + "p1000_multi" + ] + }, + "pipette-model": { + "description": "DEPRECATED. Exists to support backwards compatibility", + "type": "string", + "$comment": "TODO: Ian 2018-11-06 remove this def and every usage of it", + "enum": [ + "p10_single_v1", + "p10_multi_v1", + "p50_single_v1", + "p50_multi_v1", + "p300_single_v1", + "p300_multi_v1", + "p1000_single_v1", + "p1000_multi_v1", + + "p10_single_v1.3", + "p10_multi_v1.3", + "p50_single_v1.3", + "p50_multi_v1.3", + "p300_single_v1.3", + "p300_multi_v1.3", + "p1000_single_v1.3", + "p1000_multi_v1.3" + ] + }, + + "mm-offset": { + "description": "Millimeters for pipette location offsets", + "type": "number" + }, + + "flow-rate-for-pipettes": { + "description": "Flow rate in mm/sec for each pipette model used in the protocol", + "type": "object", + "propertyNames": {"$ref": "#/definitions/pipetteName"}, + "patternProperties": {".*": {"type": "number"}}, + "additionalProperties": false + }, + + "flow-rate-params": { + "properties": { + "flow-rate": { + "description": "Flow rate for aspirate/dispense. If omitted, defaults to the corresponding values in \"default-values\"", + "type": "number" + } + } + }, + + "offsetFromBottomMm": { + "description": "Offset from bottom of well in millimeters", + "properties": { + "offsetFromBottomMm": {"$ref": "#/definitions/mm-offset"} + } + }, + + "pipette-access-params": { + "required": ["pipette", "labware", "well"], + "properties": { + "pipette": { + "type": "string" + }, + "labware": { + "type": "string" + }, + "well": { + "type": "string" + } + } + }, + + "volume-params": { + "required": ["volume"], + "volume": { + "type": "number" + } + }, + + "slot": { + "description": "Slot on the deck of an OT-2 robot", + "type": "string", + "enum": ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"] + } + }, + + "type": "object", + "additionalProperties": false, + "required": [ + "protocol-schema", + "default-values", + "metadata", + "robot", + "pipettes", + "labware", + "procedure" + ], + "properties": { + "protocol-schema": { + "description": "A version string for the Opentrons JSON Protocol schema being used. \"..\"", + "type": "string" + }, + + "metadata": { + "description": "Optional metadata about the protocol", + "type": "object", + + "properties": { + "protocol-name": { + "description": "A short, human-readable name for the protocol", + "type": "string" + }, + "author": { + "description": "The author or organization who created the protocol", + "type": "string" + }, + "description": { + "description": "A text description of the protocol. For guidelines about how to write a good description, see (TODO WRITE DOCS & LINK HERE)", + "type": ["string", "null"] + }, + + "created": { + "description": "UNIX timestamp when this file was created", + "type": "number" + }, + "last-modified": { + "description": "UNIX timestamp when this file was last modified", + "type": ["number", "null"] + }, + + "category": { + "description": "Category of protocol (eg, \"Basic Pipetting\")", + "type": ["string", "null"] + }, + "subcategory": { + "description": "Subcategory of protocol (eg, \"Cell Plating\")", + "type": ["string", "null"] + }, + "tags": { + "description": "Tags to be used in searching for this protocol", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + + "default-values": { + "description": "Default values required for protocol execution", + "type": "object", + "$note": "TODO: Ian 2018-10-29 make touch-tip-mm-from-top required (breaking change)", + "required": [ + "aspirate-flow-rate", + "dispense-flow-rate", + "aspirate-mm-from-bottom", + "dispense-mm-from-bottom" + ], + "properties": { + "aspirate-flow-rate": {"$ref": "#/definitions/flow-rate-for-pipettes"}, + "dispense-flow-rate": {"$ref": "#/definitions/flow-rate-for-pipettes"}, + "aspirate-mm-from-bottom": {"$ref": "#/definitions/mm-offset"}, + "dispense-mm-from-bottom": {"$ref": "#/definitions/mm-offset"}, + "touch-tip-mm-from-top": {"$ref": "#/definitions/mm-offset"} + } + }, + + "designer-application": { + "description": "Optional data & metadata not required to execute the protocol, used by the application that created this protocol", + "type": "object", + "properties": { + "application-name": { + "description": "Name of the application that created the protocol. Should be namespaced under the organization or individual who owns the organization, and be kebab-cased, eg \"opentrons/protocol-designer\"", + "type": "string" + }, + "application-version": { + "description": "Version of the application that created the protocol", + "type": "string" + }, + "data": { + "description": "Any data used by the application that created this protocol)", + "type": "object" + } + } + }, + + "robot": { + "required": ["model"], + "properties": { + "model": { + "description": "Model of the robot this (currently only OT-2 Standard is supported)", + "type": "string", + "enum": ["OT-2 Standard"] + } + } + }, + + "pipettes": { + "description": "The pipettes used in this protocol, keyed by an arbitrary unique ID", + "additionalProperties": false, + "$comment": "TODO: Ian 2018-11-06 next breaking change, drop 'model' and require 'name' field", + "patternProperties": { + ".+": { + "description": "Fields describing an individual pipette", + "type": "object", + "required": ["mount", "model"], + "properties": { + "mount": { + "description": "Where the pipette is mounted", + "type": "string", + "enum": ["left", "right"] + }, + "name": { + "$ref": "#/definitions/pipetteName" + }, + "model": { + "$ref": "#/definitions/pipette-model" + } + } + } + } + }, + + "labware": { + "description": "The labware used in this protocol, keyed by an arbitrary unique ID", + "patternProperties": { + ".+": { + "description": "Fields describing a single labware on the deck", + "type": "object", + "required": ["slot", "model"], + "properties": { + "slot": {"$ref": "#/definitions/slot"}, + "model": { + "description": "Labware type, eg \"96-flat\". See http://docs.opentrons.com/containers.html for a full list of supported labware. TODO support custom labware in JSON", + "type": "string" + }, + + "display-name": { + "description": "An optional human-readable nickname for this labware. Eg \"Buffer Trough\"", + "type": "string" + } + } + } + } + }, + + "procedure": { + "description": "An array of \"subprocedure\" objects representing the steps to be executed on the robot", + "type": "array", + "items": { + "type": "object", + "required": ["subprocedure"], + "properties": { + "annotation": { + "description": "Optional info annotating the subprocedure", + "type": "object", + "required": ["name", "description"], + "properties": { + "name": { + "type": ["string", "null"] + }, + "description": { + "type": ["string", "null"] + } + } + }, + + "subprocedure": { + "type": "array", + "items": { + "anyOf": [ + { + "description": "Aspirate / dispense / air gap commands", + "type": "object", + "required": ["command", "params"], + "additionalProperties": false, + "properties": { + "command": { + "enum": ["aspirate", "dispense", "air-gap"] + }, + "params": { + "allOf": [ + {"$ref": "#/definitions/flow-rate-params"}, + {"$ref": "#/definitions/pipette-access-params"}, + {"$ref": "#/definitions/volume-params"}, + {"$ref": "#/definitions/offsetFromBottomMm"} + ] + } + } + }, + + { + "description": "Touch tip commands", + "type": "object", + "required": ["command", "params"], + "additionalProperties": false, + "properties": { + "command": { + "enum": ["touch-tip"] + }, + "params": { + "allOf": [ + {"$ref": "#/definitions/pipette-access-params"}, + {"$ref": "#/definitions/offsetFromBottomMm"} + ] + } + } + }, + + + { + "description": "Pick up tip / drop tip / blowout commands", + "type": "object", + "required": ["command", "params"], + "additionalProperties": false, + "properties": { + "command": { + "enum": ["pick-up-tip", "drop-tip", "blowout"] + }, + "params": { + "allOf": [ + {"$ref": "#/definitions/pipette-access-params"} + ] + } + } + }, + + { + "description": "Move to slot command. NOTE: this is an EXPERIMENTAL command, its behavior is subject to change in future releases.", + "type": "object", + "required": ["command", "params"], + "additionalProperties": false, + "properties": { + "command": {"enum": ["move-to-slot"]}, + "params": { + "type": "object", + "required": ["pipette", "slot"], + "additionalProperties": false, + "properties": { + "pipette": {"type": "string"}, + "slot": {"$ref": "#/definitions/slot"}, + "offset": { + "description": "Optional offset from slot bottom left corner, in mm", + "type": "object", + "required": ["x", "y", "z"], + "properties": { + "x": {"type": "number"}, + "y": {"type": "number"}, + "z": {"type": "number"} + } + }, + "minimum-z-height": { + "description": "Optional minimal Z margin in mm. If this is larger than the API's default safe Z margin, it will make the arc higher. If it's smaller, it will have no effect. Specifying this for movements that would not arc (moving within the same well in the same labware) will cause an arc movement instead. This param only supported in API v2, API v1 will ignore it.", + "type": "number", + "minimum": 0 + }, + "force-direct": { + "description": "Default is false. If true, moving from one labware/well to another will not arc to the default safe z, but instead will move directly to the specified location. This will also force the 'minimum-z-height' param to be ignored. In APIv1, this will use strategy='direct', which moves first in X/Y plane and then in Z. In API v2, a 'direct' movement is in X/Y/Z simultaneously", + "type": "boolean" + } + } + } + } + }, + + { + "description": "Delay command", + "type": "object", + "required": ["command", "params"], + "additionalProperties": false, + "properties": { + "command": { + "enum": ["delay"] + }, + "params": { + "type": "object", + "additionalProperties": false, + "required": ["wait"], + "properties": { + "wait": { + "description": "either a number of seconds to wait (fractional values OK), or `true` to wait indefinitely until the user manually resumes the protocol", + "anyOf": [ + {"type": "number"}, + {"enum": [true]} + ] + }, + "message": { + "description": "optional message describing the delay" + } + } + } + } + } + ] + } + } + } + } + } + } +}