Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
6367bd6
fix(protocol-designer): hydrate Form.tipRack to support custom tiprac…
ddcc4 Nov 4, 2025
02ff5f8
refactor(protocol-designer): 8.6.3 release notes refinement (#20036)
jerader Nov 5, 2025
4e9bce1
refactor(app,components): more renaming getAllLabwareDefs() -> getAll…
ddcc4 Nov 5, 2025
74a7708
refactor(protocol-designer): getMaxConditioningVolume() takes tiprack…
ddcc4 Nov 5, 2025
a4ed379
fix(protocol-designer): getMatchingTipLiquidSpecs() take tiprackDef d…
ddcc4 Nov 5, 2025
3e32214
refactor(protocol-designer): merge getMatchingTipLiquidSpecs() and ge…
ddcc4 Nov 5, 2025
72dc38a
fix(api): fix the dynamic pipetting location validation (#20033)
ryanthecoder Nov 5, 2025
ba8aae6
fix(robot-server, api): Ensure live stream visible during run setup (…
CaseyBatten Nov 5, 2025
7ba2feb
refactor(app): Support livestream during run setup (#20045)
mjhuff Nov 5, 2025
f969b11
feat(app): Mixpanel Analytics for Camera (#19974)
rclarke0 Nov 6, 2025
da533a8
Merge back 'chore_release-pd-8.6.3' into 'chore_release-8.8.0' (#20053)
ddcc4 Nov 6, 2025
4ed1529
fix(api, robot-server): Ensure image filename formatting is platform …
CaseyBatten Nov 6, 2025
4887a71
fix(api): Default values for thermocycler max block volume when well …
rclarke0 Nov 6, 2025
370b7d9
refactor(app): Update zipfile timestamp to reflect robot-server filen…
mjhuff Nov 6, 2025
8ecd356
fix(api): Ensure the backend uses the ot_system_camera (#20068)
CaseyBatten Nov 7, 2025
7be99d3
fix(app): copy change for camera tab in protocol setup (#20069)
felixliuopentrons Nov 7, 2025
d0b2708
fix(ci, slack): fix the notification, and don't fail (#20075)
neo-jesse Nov 7, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 2 additions & 40 deletions api/src/opentrons/protocol_api/instrument_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -336,9 +336,7 @@ def aspirate( # noqa: C901
end_move_to_location: Optional[types.Location] = None
end_meniscus_tracking: Optional[types.MeniscusTrackingTarget] = None
if end_location is not None:
end_target: Optional[validation.ValidTarget] = None
if location is None:
raise ValueError("Location must be supplied if using an End Location.")
validation.validate_dynamic_locations(location, end_location)
end_target = validation.validate_location(
location=end_location, last_location=None
)
Expand All @@ -351,14 +349,6 @@ def aspirate( # noqa: C901
end_well,
end_meniscus_tracking,
) = self._handle_aspirate_target(target=end_target)
elif (
meniscus_tracking is not None
and meniscus_tracking == types.MeniscusTrackingTarget.DYNAMIC
):
# Preserve the old behavior
meniscus_tracking = types.MeniscusTrackingTarget.START
end_move_to_location = move_to_location
end_meniscus_tracking = types.MeniscusTrackingTarget.END
if self.api_version >= APIVersion(2, 11):
instrument.validate_takes_liquid(
location=move_to_location,
Expand Down Expand Up @@ -625,9 +615,7 @@ def dispense( # noqa: C901
end_move_to_location: Optional[types.Location] = None
end_meniscus_tracking: Optional[types.MeniscusTrackingTarget] = None
if end_location is not None:
end_target: Optional[validation.ValidTarget] = None
if location is None:
raise ValueError("Location must be supplied if using an End Location.")
validation.validate_dynamic_locations(location, end_location)
end_target = validation.validate_location(
location=end_location, last_location=None
)
Expand All @@ -640,14 +628,6 @@ def dispense( # noqa: C901
end_well,
end_meniscus_tracking,
) = self._handle_dispense_target(target=end_target)
elif (
meniscus_tracking is not None
and meniscus_tracking == types.MeniscusTrackingTarget.DYNAMIC
):
# Preserve the old behavior
meniscus_tracking = types.MeniscusTrackingTarget.START
end_move_to_location = move_to_location
end_meniscus_tracking = types.MeniscusTrackingTarget.END

if self.api_version >= APIVersion(2, 11):
instrument.validate_takes_liquid(
Expand Down Expand Up @@ -964,24 +944,6 @@ def dynamic_mix( # noqa: C901
raise ValueError(
"Aspirate and Dispense locations must be within the same well"
)
if aspirate_end_location is not None:
(
_,
asp_end_well,
) = aspirate_end_location.labware.get_parent_labware_and_well()
if asp_start_well != asp_end_well:
raise ValueError(
"Aspirate start and end locations must be within the same well"
)
if dispense_end_location is not None:
(
_,
disp_end_well,
) = dispense_end_location.labware.get_parent_labware_and_well()
if disp_start_well != disp_end_well:
raise ValueError(
"Dispense start and end locations must be within the same well"
)

if not self._core.has_tip():
raise UnexpectedTipRemovalError("mix", self.name, self.mount)
Expand Down
6 changes: 5 additions & 1 deletion api/src/opentrons/protocol_api/module_contexts.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
from .labware import Labware
from . import validation
from . import Task

from opentrons.drivers.thermocycler.driver import BLOCK_VOL_MIN, BLOCK_VOL_MAX

_MAGNETIC_MODULE_HEIGHT_PARAM_REMOVED_IN = APIVersion(2, 14)

Expand Down Expand Up @@ -981,6 +981,10 @@ def _get_current_labware_max_vol(self) -> Optional[float]:
# ignore simulated probe results
if isinstance(well_vol, float):
max_vol = max(max_vol, well_vol)
if max_vol > BLOCK_VOL_MAX:
max_vol = BLOCK_VOL_MAX
elif max_vol < BLOCK_VOL_MIN:
max_vol = BLOCK_VOL_MIN
return max_vol


Expand Down
34 changes: 34 additions & 0 deletions api/src/opentrons/protocol_api/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -577,6 +577,40 @@ class LocationTypeError(TypeError):
ValidTarget = Union[WellTarget, PointTarget, DisposalTarget]


def validate_dynamic_locations(
location: Optional[Union[Location, Well, TrashBin, WasteChute]],
end_location: Location,
) -> None:
"""Given that we have an end_location we check that they're a vaild dynamic pair."""
if location is None:
raise ValueError("Location must be supplied if using an End Location.")
if not isinstance(location, Location):
raise ValueError(
"Location must be a point within a well when dynamic pipetting."
)
# Shouldn't be true ever if using typing but a customer protocol may not check
if not isinstance(end_location, Location):
raise ValueError(
"End location must be a point within a well when dynamic pipetting."
)
if not location.labware.is_well:
raise ValueError("Start location must be within a well when dynamic pipetting")
if not end_location.labware.is_well:
raise ValueError("End location must be within a well when dynamic pipetting")
(
_,
start_well,
) = location.labware.get_parent_labware_and_well()
(
_,
end_well,
) = end_location.labware.get_parent_labware_and_well()
if start_well != end_well:
raise ValueError(
"Start and end locations must be within the same well when dynamic pipetting"
)


def validate_location(
location: Optional[Union[Location, Well, TrashBin, WasteChute]],
last_location: Optional[Union[Location, TrashBin, WasteChute]],
Expand Down
10 changes: 10 additions & 0 deletions api/src/opentrons/protocol_engine/commands/capture_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,15 @@
from .command import AbstractCommandImpl, BaseCommand, BaseCommandCreate, SuccessData
from ..errors import (
CameraDisabledError,
FileNameInvalidError,
)
from ..errors.error_occurrence import ErrorOccurrence

from ..resources.file_provider import (
ImageCaptureCmdFileNameMetadata,
)
from ..resources import FileProvider
from ..resources.file_provider import SPECIAL_CHARACTERS
from ..resources import CameraProvider
from ..resources.camera_provider import ImageParameters
from ..state import update_types
Expand Down Expand Up @@ -183,6 +185,14 @@ async def execute(
{PreconditionTypes.IS_CAMERA_USED: True}
)

# Validate the filename param provided to fail analysis
if params.fileName is not None and set(SPECIAL_CHARACTERS).intersection(
set(params.fileName)
):
raise FileNameInvalidError(
message=f"Capture image filename cannot contain character(s): {SPECIAL_CHARACTERS.intersection(set(params.fileName))}"
)

# Handle capturing an image with the CameraProvider - Engine camera settings take priority
camera_settings = await self._camera_provider.get_camera_settings()
engine_camera_settings = self._state_view.camera.get_enablement_settings()
Expand Down
2 changes: 2 additions & 0 deletions api/src/opentrons/protocol_engine/errors/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@
OperationLocationNotInWellError,
InvalidDispenseVolumeError,
StorageLimitReachedError,
FileNameInvalidError,
InvalidLiquidError,
LiquidClassDoesNotExistError,
LiquidClassRedefinitionError,
Expand Down Expand Up @@ -193,6 +194,7 @@
"OperationLocationNotInWellError",
"InvalidDispenseVolumeError",
"StorageLimitReachedError",
"FileNameInvalidError",
"LiquidClassDoesNotExistError",
"LiquidClassRedefinitionError",
"CameraCaptureError",
Expand Down
13 changes: 13 additions & 0 deletions api/src/opentrons/protocol_engine/errors/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -1291,6 +1291,19 @@ def __init__(
super().__init__(ErrorCodes.GENERAL_ERROR, message, detail, wrapping)


class FileNameInvalidError(ProtocolEngineError):
"""Raised to indicate that a file cannot be saved with a given name."""

def __init__(
self,
message: Optional[str] = None,
detail: Optional[Dict[str, str]] = None,
wrapping: Optional[Sequence[EnumeratedError]] = None,
) -> None:
"""Build an FileNameInvalidError."""
super().__init__(ErrorCodes.GENERAL_ERROR, message, detail, wrapping)


class LiquidClassDoesNotExistError(ProtocolEngineError):
"""Raised when referencing a liquid class that has not been loaded."""

Expand Down
25 changes: 25 additions & 0 deletions api/src/opentrons/protocol_engine/resources/file_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,31 @@
RunFileNameMetadata,
)

SPECIAL_CHARACTERS = {
"#",
"%",
"&",
"{",
"}",
"\\",
"/",
"<",
">",
"*",
" ",
"$",
"!",
"?",
".",
"'",
'"',
":",
";",
"@",
"`",
"|",
}


@dataclass(frozen=True)
class FileNameCmdMetadata:
Expand Down
8 changes: 0 additions & 8 deletions api/src/opentrons/protocol_runner/run_orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
from opentrons_shared_data.labware.labware_definition import LabwareDefinition
from opentrons_shared_data.errors import GeneralError
from opentrons_shared_data.robot.types import RobotType
from opentrons.system import camera

from . import protocol_runner, RunResult, JsonRunner, PythonAndLegacyRunner
from ..hardware_control import HardwareControlAPI
Expand Down Expand Up @@ -195,13 +194,6 @@ async def run(
run_time_param_values: Optional[PrimitiveRunTimeParamValuesType] = None,
) -> RunResult:
"""Start the run."""
if self._camera_provider:
await camera.update_live_stream_status(
self.get_robot_type(),
True,
self._camera_provider,
self._protocol_engine.state_view.camera.get_enablement_settings(),
)
if self._protocol_runner:
return await self._protocol_runner.run(
deck_configuration=deck_configuration,
Expand Down
9 changes: 3 additions & 6 deletions api/src/opentrons/system/camera.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,7 @@
log = logging.getLogger(__name__)

# Default System Cameras
FLEX_EMBEDDED_CAMERA = "/dev/video2"
OT2_CAMERA = "/dev/video0"
DEFAULT_SYSTEM_CAMERA = "/dev/ot_system_camera"

# Stream Globals
DEFAULT_CONF_FILE = (
Expand Down Expand Up @@ -160,7 +159,7 @@ async def update_live_stream_status(
raw_device = str(contents["SOURCE"])[1:-1]
if not os.path.exists(raw_device):
log.error(
"Opentrons Live Stream cannot sample the camera. No video device found with device path: {raw_device}"
f"Opentrons Live Stream cannot sample the camera. No video device found with device path: {raw_device}"
)
# Enable the stream
status = "ON"
Expand Down Expand Up @@ -278,9 +277,7 @@ async def image_capture(
robot_type: RobotType, parameters: ImageParameters
) -> bytes | CameraError:
"""Process an Image Capture request with a Camera utilizing a given set of parameters."""
camera = (
FLEX_EMBEDDED_CAMERA if ARCHITECTURE == SystemArchitecture.YOCTO else OT2_CAMERA
)
camera = DEFAULT_SYSTEM_CAMERA

# We must always validate the camera exists
if not os.path.exists(camera):
Expand Down
4 changes: 4 additions & 0 deletions api/src/opentrons/system/ffmpeg.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ async def ffmpeg_capture_image_bytes(
"auto",
"-video_size",
f"{resolution[0]}x{resolution[1]}",
"-f",
"v4l2",
"-i",
f"{camera}",
"-vf",
Expand All @@ -66,6 +68,8 @@ async def ffmpeg_capture_image_bytes(
"auto",
"-video_size",
f"{resolution[0]}x{resolution[1]}",
"-f",
"v4l2",
"-i",
f"{camera}",
"-vf",
Expand Down
Loading
Loading