Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
9f42aba
fix(api): fix a zero move length error (#20095)
ryanthecoder Nov 10, 2025
7ed8d0c
fix(robot-server): Ensure camera device existence is validate for all…
CaseyBatten Nov 10, 2025
068dbc8
fix(app): default camera usage state to `disabled` (#20104)
mjhuff Nov 10, 2025
7e313a3
fix(build): build py dists before deploy
sfoster1 Nov 10, 2025
6c72679
Revert "fix(build): build py dists before deploy"
sfoster1 Nov 10, 2025
8048d4e
fix(odd): stop polluting console log with "▶ Object" (#20111)
ddcc4 Nov 10, 2025
ebc602a
refactor(app): specify camera type based on robot type (#20099)
rclarke0 Nov 12, 2025
1855e0f
fix(hardware): Fix plunger stalls during negative direction motion. (…
ryanthecoder Nov 12, 2025
e9fc062
fix(api): Capture images on error even when exception is interrupted …
CaseyBatten Nov 12, 2025
9fa3f59
refactor(app): update to command schema 15 for QT (#20131)
jerader Nov 12, 2025
1af52d0
fix(build): build py dists before deploy (#20108)
sfoster1 Nov 12, 2025
2a6f66a
fix(app): conditionally render the livestream toggle by robot type (#…
mjhuff Nov 13, 2025
6596a1d
fix(api): Ensure PAPI provided params are within limits (#20136)
CaseyBatten Nov 13, 2025
3b07f42
fix(api): Enable use of spacebars in filenames for images (#20133)
CaseyBatten Nov 13, 2025
ca029fe
refactor(app): copy changes to livestream window and error image capt…
rclarke0 Nov 13, 2025
27e5059
fix(docs): swap comment on set_offset() and calibrated_offset() (#20123)
ddcc4 Nov 13, 2025
820fab0
Merge back 'chore_release-8.8.0' into 'edge' (#20140)
ddcc4 Nov 13, 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
4 changes: 4 additions & 0 deletions .github/workflows/api-test-lint-deploy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,10 @@ jobs:
- uses: './.github/actions/python/setup'
with:
project: 'api'
- name: 'build api distributables'
shell: bash
run: make -C api sdist wheel

# creds and repository configuration for deploying python wheels
- if: ${{ !env.OT_TAG }}
name: 'upload to test pypi'
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/shared-data-test-lint-deploy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,10 @@ jobs:
script: |
const { buildComplexEnvVars, } = require(`${process.env.GITHUB_WORKSPACE}/.github/workflows/utils.js`)
buildComplexEnvVars(core, context)
- name: 'build shared-data wheel'
shell: bash
run: make -C shared-data dist-py

# creds and repository configuration for deploying python wheels
- if: ${{ !env.OT_TAG }}
name: 'upload to test pypi'
Expand Down
6 changes: 3 additions & 3 deletions api/src/opentrons/hardware_control/backends/ot3controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -664,11 +664,11 @@ def _build_move_node_axis_runner(
return None, False
# Create a target that doesn't incorporate the plunger into a joint axis with the gantry
plunger_axes = [Axis.P_L, Axis.P_R]
move_target = self._move_manager.devectorize_axes(
origin, target, speed, plunger_axes
)

try:
move_target = self._move_manager.devectorize_axes(
origin, target, speed, plunger_axes
)
_, movelist = self._move_manager.plan_motion(
origin=origin, target_list=[move_target]
)
Expand Down
7 changes: 5 additions & 2 deletions api/src/opentrons/protocol_api/labware.py
Original file line number Diff line number Diff line change
Expand Up @@ -822,8 +822,8 @@ def set_calibration(self, delta: Point) -> None:
def set_offset(self, x: float, y: float, z: float) -> None:
"""Set the labware's position offset.

The offset is an x, y, z vector in deck coordinates
(see :ref:`protocol-api-deck-coords`).
An offset of `(x=0, y=0, z=0)` means the labware's uncalibrated position before
any offset from Labware Position Check is applied.

How the motion system applies the offset depends on the API level of the protocol.

Expand Down Expand Up @@ -882,6 +882,9 @@ def set_offset(self, x: float, y: float, z: float) -> None:
def calibrated_offset(self) -> Point:
"""The front-left-bottom corner of the labware, including its labware offset.

The offset is an x, y, z vector in deck coordinates
(see :ref:`protocol-api-deck-coords`).

When running a protocol in the Opentrons App or on the touchscreen, Labware
Position Check sets the labware offset.
"""
Expand Down
21 changes: 21 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,6 +19,7 @@
from .command import AbstractCommandImpl, BaseCommand, BaseCommandCreate, SuccessData
from ..errors import (
CameraDisabledError,
CameraSettingsInvalidError,
FileNameInvalidError,
)
from ..errors.error_occurrence import ErrorOccurrence
Expand Down Expand Up @@ -193,6 +194,26 @@ async def execute(
message=f"Capture image filename cannot contain character(s): {SPECIAL_CHARACTERS.intersection(set(params.fileName))}"
)

# Validate the image filter parameters
if params.brightness is not None and (
params.brightness < 0 or params.brightness > 100
):
raise CameraSettingsInvalidError(
message="Capture image brightness must be a percentage from 0% to 100%."
)
if params.contrast is not None and (
params.contrast < 0 or params.contrast > 100
):
raise CameraSettingsInvalidError(
message="Capture image contrast must be a percentage from 0% to 100%."
)
if params.saturation is not None and (
params.saturation < 0 or params.saturation > 100
):
raise CameraSettingsInvalidError(
message="Capture image saturation must be a percentage from 0% to 100%."
)

# 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
12 changes: 10 additions & 2 deletions api/src/opentrons/protocol_engine/execution/command_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ async def execute(self, command_id: str) -> None:
log.debug(
f"Executing {running_command.id}, {running_command.commandType}, {running_command.params}"
)
error_occurred = False
try:
result = await command_impl.execute(
running_command.params # type: ignore[arg-type]
Expand Down Expand Up @@ -191,7 +192,7 @@ async def execute(self, command_id: str) -> None:
type=error_recovery_type,
)
)
await self.capture_error_image(running_command)
error_occurred = True

else:
if isinstance(result, SuccessData):
Expand Down Expand Up @@ -227,6 +228,10 @@ async def execute(self, command_id: str) -> None:
type=error_recovery_type,
)
)
error_occurred = True
finally:
# Handle error image capture if appropriate
if error_occurred:
await self.capture_error_image(running_command)

def cancel_tasks(self, message: str | None = None) -> None:
Expand All @@ -236,7 +241,10 @@ def cancel_tasks(self, message: str | None = None) -> None:
async def capture_error_image(self, running_command: Command) -> None:
"""Capture an image of an error event."""
try:
camera_enablement = await self._camera_provider.get_camera_settings()
camera_enablement = self._state_store.camera.get_enablement_settings()
if camera_enablement is None:
# Utilize the global camera settings
camera_enablement = await self._camera_provider.get_camera_settings()
# Only capture photos of errors if the setting to do so is enabled
if (
camera_enablement.cameraEnabled
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@
"<",
">",
"*",
" ",
"$",
"!",
"?",
Expand Down
18 changes: 17 additions & 1 deletion api/src/opentrons/system/camera.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@
]

# Camera Parameter Globals
RESOLUTION_MIN = (320, 240)
RESOLUTION_MAX = (7680, 4320)
RESOLUTION_DEFAULT = (1920, 1080)
ZOOM_MIN = 1.0
ZOOM_MAX = 2.0
Expand Down Expand Up @@ -277,7 +279,7 @@ def write_stream_configuration_file_data(data: Dict[str, str]) -> None:
fd.writelines(file_lines)


async def image_capture(
async def image_capture( # noqa: C901
robot_type: RobotType, parameters: ImageParameters
) -> bytes | CameraError:
"""Process an Image Capture request with a Camera utilizing a given set of parameters."""
Expand Down Expand Up @@ -305,8 +307,16 @@ async def image_capture(
parameters.saturation < SATURATION_MIN or parameters.saturation > SATURATION_MAX
):
potential_invalid_param = "Saturation"
elif parameters.resolution is not None and (
parameters.resolution[0] < RESOLUTION_MIN[0]
or parameters.resolution[1] < RESOLUTION_MIN[1]
or parameters.resolution[0] > RESOLUTION_MAX[0]
or parameters.resolution[1] > RESOLUTION_MAX[1]
):
potential_invalid_param = "Resolution"
else:
potential_invalid_param = None

if potential_invalid_param is not None:
return CameraError(
message=f"{potential_invalid_param} parameter is outside the boundaries allowed for image capture.",
Expand Down Expand Up @@ -363,3 +373,9 @@ def get_boot_id() -> str:
return Path("/proc/sys/kernel/random/boot_id").read_text().strip()
else:
return "SIMULATED_BOOT_ID"


def camera_exists() -> bool:
"""Validate whether or not the camera device exists."""
return os.path.exists(DEFAULT_SYSTEM_CAMERA)
# todo(chb, 2025-11-10): Eventually when we support multiple cameras this should accept a camera parameter to check for
Original file line number Diff line number Diff line change
Expand Up @@ -1454,6 +1454,32 @@ def move_group_run_side_effect(
},
None,
],
[
{
Axis.X: 0,
Axis.Y: 0,
Axis.Z_L: 0,
Axis.Z_R: 0,
Axis.P_L: 0,
},
{
Axis.X: 0, # Zero Length Move, make sure it doesn't raise an error
Axis.Y: 0,
Axis.Z_L: 0,
Axis.P_L: 0,
},
{
Axis.X: 0,
Axis.Y: 0,
Axis.Z_L: 0,
Axis.Z_R: 0,
Axis.P_L: 0,
Axis.P_R: 0,
Axis.Z_G: 0,
Axis.G: 0,
},
None,
],
],
)
async def test_controller_move(
Expand Down
65 changes: 59 additions & 6 deletions api/tests/opentrons/protocol_engine/commands/test_capture_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,8 +240,8 @@ async def test_ensure_camera_used_precondition_set(
50,
],
[
(1, 2),
(1, 2),
(320, 240),
(320, 240),
1.5,
1.5,
(3, 4),
Expand All @@ -254,8 +254,8 @@ async def test_ensure_camera_used_precondition_set(
75,
],
[
(1, 2),
(1, 2),
(320, 240),
(320, 240),
2.0,
2.0,
(3, 4),
Expand All @@ -268,8 +268,8 @@ async def test_ensure_camera_used_precondition_set(
25,
],
[
(9999999, 9999999),
(9999999, 9999999),
(7680, 4320),
(7680, 4320),
1.0,
1.0,
(25, 45),
Expand Down Expand Up @@ -389,3 +389,56 @@ async def test_raises_filename_error(
params = CaptureImageParams(fileName="badname" + char)
with pytest.raises(FileNameInvalidError):
await subject.execute(params=params)


@pytest.mark.parametrize(
argnames=[
"zoom",
"contrast",
"brightness",
"saturation",
"resolution",
],
argvalues=[
[0.9, 1, 1, 1, (1920, 1080)],
[2.1, 1, 1, 1, (1920, 1080)],
[1, -1, 1, 1, (1920, 1080)],
[1, 101, 1, 1, (1920, 1080)],
[1, 1, -1, 1, (1920, 1080)],
[1, 1, 101, 1, (1920, 1080)],
[1, 1, 1, -1, (1920, 1080)],
[1, 1, 1, 101, (1920, 1080)],
[1, 1, 1, 1, (0, 0)],
[1, 1, 1, 1, (10000, 10000)],
],
)
async def test_raises_image_parameter_error(
decoy: Decoy,
state_view: StateView,
file_provider: FileProvider,
camera_provider_image_capture: CameraProvider,
zoom: float,
contrast: float,
brightness: float,
saturation: float,
resolution: Tuple[int, int],
) -> None:
"""It should raise CameraSettingsInvalidError when the capture image command is provided bad filter params."""
subject = CaptureImageImpl(
state_view=state_view,
file_provider=file_provider,
camera_provider=camera_provider_image_capture,
)
params = CaptureImageParams(
resolution=resolution,
zoom=zoom,
contrast=contrast,
brightness=brightness,
saturation=saturation,
)

decoy.when(state_view.files.get_filecount()).then_return(0)

with mock.patch("os.path.exists", mock.Mock(return_value=True)):
with pytest.raises(CameraSettingsInvalidError):
await subject.execute(params=params)
5 changes: 3 additions & 2 deletions app/src/assets/localization/en/anonymous.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"estop_pressed_description": "First, safely clear the deck of any labware or spills. Then, twist the E-stop button clockwise. Finally, have the robot move the gantry to its home position.",
"find_your_robot": "Find your robot in the Devices section of the app to install software updates.",
"firmware_update_download_logs": "Contact support for assistance.",
"flex_camera": "Camera",
"flex_stacker_empty": "Manually empty all labware from robot Stacker",
"flex_stacker_empty_from_location": "Manually empty all labware from robot {{stackerColumn}}",
"flex_stacker_fill": "Fill robot Stacker",
Expand All @@ -46,8 +47,8 @@
"labware_offsets_conflict_description": "Your robot's stored labware offsets were updated after the last protocol run on <strong>{{timestamp}}</strong>. Which offsets do you want to use to run this protocol again?",
"language_preference_description": "The app matches your system language unless you select another language below. You can change the language later in the app settings.",
"learn_uninstalling": "Learn more about uninstalling the app",
"livestream_window_title": "Robot Live Camera",
"live_video_description_odd": "View real-time video of the deck in the App while running a protocol.",
"livestream_window_title": "Robot {{robotName}} Live Camera",
"live_video_description_odd": "View real-time video of the deck in the desktop app while running a protocol.",
"loosen_screws_and_detach": "Loosen screws and detach gripper",
"modal_instructions": "For step-by-step instructions on setting up your module, consult the Quickstart Guide that came in its box.",
"module_calibration_failed": "<block>Module calibration was unsuccessful. Make sure the calibration adapter is fully seated on the module and try again. If you still have trouble, contact support.</block><block>{{error}}</block>",
Expand Down
3 changes: 2 additions & 1 deletion app/src/assets/localization/en/branded.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"estop_pressed_description": "First, safely clear the deck of any labware or spills. Then, twist the E-stop button clockwise. Finally, have Flex move the gantry to its home position.",
"find_your_robot": "Find your robot in the Opentrons App to install software updates.",
"firmware_update_download_logs": "Download the robot logs from the Opentrons App and send them to [email protected] for assistance.",
"flex_camera": "Flex Camera",
"flex_stacker_empty": "Manually empty all labware from Flex Stacker",
"flex_stacker_empty_from_location": "Manually empty all labware from Flex {{stackerColumn}}",
"flex_stacker_fill": "Fill Flex Stacker",
Expand All @@ -46,7 +47,7 @@
"labware_offsets_conflict_description": "Your Flex's stored labware offsets were updated after the last protocol run on <strong>{{timestamp}}</strong>. Which offsets do you want to use to run this protocol again?",
"language_preference_description": "The Opentrons App matches your system language unless you select another language below. You can change the language later in the app settings.",
"learn_uninstalling": "Learn more about uninstalling the Opentrons App",
"livestream_window_title": "Opentrons Live Camera",
"livestream_window_title": "Opentrons {{robotName}} Live Camera",
"live_video_description_odd": "View real-time video of the deck in the Opentrons App while running a protocol.",
"loosen_screws_and_detach": "Loosen screws and detach Flex Gripper",
"modal_instructions": "For step-by-step instructions on setting up your module, consult the Quickstart Guide that came in its box. You can also click the link below or scan the QR code to visit the modules section of the Opentrons Help Center.",
Expand Down
1 change: 1 addition & 0 deletions app/src/assets/localization/en/device_details.json
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@
"offline_recent_protocol_runs": "Robot must be on the network to see protocol runs",
"on_deck": "On Deck",
"open_lid": "Open lid",
"ot2_camera": "OT-2 Camera",
"overflow_menu_about": "About module",
"overflow_menu_deactivate_block": "Deactivate block",
"overflow_menu_deactivate_lid": "Deactivate lid",
Expand Down
7 changes: 3 additions & 4 deletions app/src/assets/localization/en/device_settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -149,9 +149,8 @@
"enter_name_security_type": "Enter the network name and security type.",
"enter_network_name": "Enter network name",
"enter_password": "Enter password",
"error_recovery": "Error Recovery",
"error_recovery_camera_description": "Automatically capture an image of the deck on error.",
"error_recovery_lc": "Error recovery",
"error_recovery_lc": "Error image capture",
"estop": "E-stop",
"estop_disengaged": "E-stop Disengaged",
"estop_engaged": "E-stop Engaged",
Expand Down Expand Up @@ -185,7 +184,7 @@
"historic_offsets_description": "Use stored data when setting up a protocol.",
"image_preview_timestamp": "Image preview {{timestamp}}",
"image_storage_almost_full": "Image storage almost full",
"image_video_settings": "Image and Video Settings",
"image_video_settings": "Image and video settings",
"image_video_settings_lc": "Image and video settings",
"incorrect_password_for_ssid": "Oops! Incorrect password for {{ssid}}",
"increase_deck_appearance": "Increase how close the deck appears.",
Expand All @@ -204,7 +203,7 @@
"launch_jupyter_notebook": "Launch Jupyter Notebook",
"legacy_settings": "Legacy Settings",
"likely_incorrect_password": "Likely incorrect network password.",
"live_video": "Live Video",
"live_video": "Live video",
"live_video_description": "View real-time video of the deck while a running a protocol",
"live_video_lc": "Live video",
"mac_address": "MAC Address",
Expand Down
Loading
Loading