diff --git a/api/src/opentrons/config/reset.py b/api/src/opentrons/config/reset.py index eb1171bd684..8cc4486aa57 100644 --- a/api/src/opentrons/config/reset.py +++ b/api/src/opentrons/config/reset.py @@ -37,6 +37,7 @@ class ResetOptionId(str, Enum): gripper_offset = "gripperOffsetCalibrations" tip_length_calibrations = "tipLengthCalibrations" runs_history = "runsHistory" + on_device_display = "onDeviceDisplay" _OT_2_RESET_OPTIONS = [ @@ -51,6 +52,7 @@ class ResetOptionId(str, Enum): ResetOptionId.pipette_offset, ResetOptionId.gripper_offset, ResetOptionId.runs_history, + ResetOptionId.on_device_display, ] _settings_reset_options = { @@ -59,7 +61,7 @@ class ResetOptionId(str, Enum): ), ResetOptionId.deck_calibration: CommonResetOption( name="Deck Calibration", - description="Clear deck calibration (will also clear pipette " "offset)", + description="Clear deck calibration (will also clear pipette offset)", ), ResetOptionId.pipette_offset: CommonResetOption( name="Pipette Offset Calibrations", @@ -71,16 +73,20 @@ class ResetOptionId(str, Enum): ), ResetOptionId.tip_length_calibrations: CommonResetOption( name="Tip Length Calibrations", - description="Clear tip length calibrations (will also clear " "pipette offset)", + description="Clear tip length calibrations (will also clear pipette offset)", ), - # TODO(mm, 2022-05-23): Run and protocol history is a robot-server thing, - # and is not a concept known to this package (the `opentrons` library). + # TODO(mm, 2022-05-23): runs_history and on_device_display are robot-server things, + # and are not concepts known to this package (the `opentrons` library). # This option is defined here only as a convenience for robot-server. - # Find a way to split thing up and define this in robot-server instead. + # Find a way to split things up and define this in robot-server instead. ResetOptionId.runs_history: CommonResetOption( name="Clear Runs History", description="Erase this device's stored history of protocols and runs.", ), + ResetOptionId.on_device_display: CommonResetOption( + name="On-Device Display Configuration", + description="Clear the configuration of the on-device display (touchscreen)", + ), } diff --git a/app-shell-odd/package.json b/app-shell-odd/package.json index 2eedb9a8fc9..2647af12ce8 100644 --- a/app-shell-odd/package.json +++ b/app-shell-odd/package.json @@ -51,6 +51,7 @@ "form-data": "2.5.0", "fs-extra": "10.0.0", "get-stream": "5.1.0", + "lodash": "4.17.21", "merge-options": "1.0.1", "node-fetch": "2.6.7", "node-stream-zip": "1.8.2", diff --git a/app-shell-odd/src/config/index.ts b/app-shell-odd/src/config/index.ts index 6f821409cb1..73041e50e64 100644 --- a/app-shell-odd/src/config/index.ts +++ b/app-shell-odd/src/config/index.ts @@ -2,6 +2,7 @@ // TODO(mc, 2020-01-31): this module is high-importance and needs unit tests import Store from 'electron-store' import get from 'lodash/get' +import forEach from 'lodash/forEach' import mergeOptions from 'merge-options' import yargsParser from 'yargs-parser' import fs from 'fs-extra' @@ -23,7 +24,7 @@ import type { Config, Overrides } from './types' export * from './types' -const ODD_DIR = '/data/ODD' +export const ODD_DIR = '/data/ODD' // Note (kj:03/02/2023) this file path will be updated when the embed team cleans up const BRIGHTNESS_FILE = @@ -150,3 +151,11 @@ export function handleConfigChange( ): void { store().onDidChange(path, changeHandler) } +export function resetStore(): void { + log().debug('Resetting the store') + store().clear() + const migratedConfig = migrate(DEFAULTS_V12) + forEach(migratedConfig, (configVal, configKey) => { + store().set(configKey, configVal) + }) +} diff --git a/app-shell-odd/src/main.ts b/app-shell-odd/src/main.ts index 51b0fa7225a..6184722fcd2 100644 --- a/app-shell-odd/src/main.ts +++ b/app-shell-odd/src/main.ts @@ -1,5 +1,7 @@ // electron main entry point import { app, ipcMain } from 'electron' +import fse from 'fs-extra' +import path from 'path' import { createUi } from './ui' import { createLogger } from './log' import { registerDiscovery } from './discovery' @@ -7,7 +9,14 @@ import { registerRobotLogs } from './robot-logs' import { registerUpdate, updateLatestVersion } from './update' import { registerRobotSystemUpdate } from './system-update' import { registerAppRestart } from './restart' -import { getConfig, getStore, getOverrides, registerConfig } from './config' +import { + getConfig, + getStore, + getOverrides, + registerConfig, + resetStore, + ODD_DIR, +} from './config' import systemd from './systemd' import type { BrowserWindow } from 'electron' @@ -41,6 +50,15 @@ app.once('window-all-closed', () => { function startUp(): void { log.info('Starting App') + console.log('Starting App') + const storeNeedsReset = fse.existsSync( + path.join(ODD_DIR, `_CONFIG_TO_BE_DELETED_ON_REBOOT`) + ) + if (storeNeedsReset) { + log.debug('store marked to be reset, resetting store') + resetStore() + fse.removeSync(path.join(ODD_DIR, `_CONFIG_TO_BE_DELETED_ON_REBOOT`)) + } systemd.sendStatus('loading app') process.on('uncaughtException', error => log.error('Uncaught: ', { error })) process.on('unhandledRejection', reason => diff --git a/app-shell/build/release-notes-internal.md b/app-shell/build/release-notes-internal.md index 486dea2b286..08663572808 100644 --- a/app-shell/build/release-notes-internal.md +++ b/app-shell/build/release-notes-internal.md @@ -14,6 +14,7 @@ This is still pretty early in the process, so some things are known not to work, - All instrument flows should display errors properly now - Update robot flows don't say OT-2s anymore - There should be fewer surprise scroll bars on Windows +- The configuration of the on-device display can be factory-reset, which lets you go back to the first-time setup flow diff --git a/app/src/assets/localization/en/device_settings.json b/app/src/assets/localization/en/device_settings.json index 9066277f49f..30e7a3a0883 100644 --- a/app/src/assets/localization/en/device_settings.json +++ b/app/src/assets/localization/en/device_settings.json @@ -29,7 +29,8 @@ "checking_for_updates": "Checking for updates", "choose_reset_settings": "Choose reset settings", "choose": "Choose...", - "clear_all_stored_data_description": "Clears instrument calibrations and protocols. Keeps robot name and network settings.", + "clear_all_data": "Clear all data", + "clear_all_stored_data_description": "Resets all settings. You’ll have to redo initial setup before using the robot again.", "clear_all_stored_data": "Clear all stored data", "clear_data_and_restart_robot": "Clear data and restart robot", "clear_individual_data": "Clear individual data", @@ -227,6 +228,7 @@ "security_type": "Security Type", "select_a_network": "Select a network", "select_a_security_type": "Select a security type", + "select_all_settings": "Select all settings", "select_authentication_method": "Select authentication method for your selected network.", "sending_software": "Sending software...", "share_logs_with_opentrons_description": "Help Opentrons improve its products and services by automatically sending anonymous robot logs. Opentrons uses these logs to troubleshoot robot issues and spot error trends.", diff --git a/app/src/organisms/Devices/RobotSettings/AdvancedTab/AdvancedTabSlideouts/DeviceResetModal.tsx b/app/src/organisms/Devices/RobotSettings/AdvancedTab/AdvancedTabSlideouts/DeviceResetModal.tsx index 9a60caad1fb..45e3d96abbf 100644 --- a/app/src/organisms/Devices/RobotSettings/AdvancedTab/AdvancedTabSlideouts/DeviceResetModal.tsx +++ b/app/src/organisms/Devices/RobotSettings/AdvancedTab/AdvancedTabSlideouts/DeviceResetModal.tsx @@ -23,7 +23,11 @@ import { SUCCESS, PENDING, } from '../../../../../redux/robot-api' -import { resetConfig } from '../../../../../redux/robot-admin' +import { + getResetConfigOptions, + resetConfig, +} from '../../../../../redux/robot-admin' +import { useIsOT3 } from '../../../hooks' import type { State } from '../../../../../redux/types' import type { ResetConfigRequest } from '../../../../../redux/robot-admin/types' @@ -44,13 +48,36 @@ export function DeviceResetModal({ const { t } = useTranslation(['device_settings', 'shared']) const history = useHistory() const [dispatchRequest, requestIds] = useDispatchApiRequest() + const isOT3 = useIsOT3(robotName) const resetRequestStatus = useSelector((state: State) => { const lastId = last(requestIds) return lastId != null ? getRequestById(state, lastId) : null })?.status + const serverResetOptions = useSelector((state: State) => + getResetConfigOptions(state, robotName) + ) + const triggerReset = (): void => { if (resetOptions != null) { + if (isOT3) { + const totalOptionsSelected = Object.values(resetOptions).filter( + selected => selected === true + ).length + + const isEveryOptionSelected = + totalOptionsSelected > 0 && + totalOptionsSelected === + // filtering out ODD setting because this gets implicitly cleared if all settings are selected + serverResetOptions.filter(o => o.id !== 'onDeviceDisplay').length + + if (isEveryOptionSelected) { + resetOptions = { + ...resetOptions, + onDeviceDisplay: true, + } + } + } dispatchRequest(resetConfig(robotName, resetOptions)) history.push(`/devices/`) } diff --git a/app/src/organisms/Devices/RobotSettings/AdvancedTab/AdvancedTabSlideouts/DeviceResetSlideout.tsx b/app/src/organisms/Devices/RobotSettings/AdvancedTab/AdvancedTabSlideouts/DeviceResetSlideout.tsx index c0605a5df01..163e0446475 100644 --- a/app/src/organisms/Devices/RobotSettings/AdvancedTab/AdvancedTabSlideouts/DeviceResetSlideout.tsx +++ b/app/src/organisms/Devices/RobotSettings/AdvancedTab/AdvancedTabSlideouts/DeviceResetSlideout.tsx @@ -131,6 +131,17 @@ export function DeviceResetSlideout({ onCloseClick() } + const totalOptionsSelected = Object.values(resetOptions).filter( + selected => selected === true + ).length + + // filtering out ODD setting because this gets implicitly cleared if all settings are selected + const allOptionsWithoutODD = options.filter(o => o.id !== 'onDeviceDisplay') + + const isEveryOptionSelected = + totalOptionsSelected > 0 && + totalOptionsSelected === allOptionsWithoutODD.length + return ( {t('resets_cannot_be_undone')} - {/* Note: (kj:06/07/2023) this part will be updated when be is ready */} {isOT3 ? ( <> - {t('factory_reset')} - - - {t('factory_reset_description')} - - - - - {t('clear_all_stored_data')} + {t('clear_all_data')} - + {t('clear_all_stored_data_description')} + { + setResetOptions( + isEveryOptionSelected + ? {} + : allOptionsWithoutODD.reduce((acc, val) => { + return { + ...acc, + [val.id]: true, + } + }, {}) + ) + }} + value={isEveryOptionSelected} + label={t(`select_all_settings`)} + isIndeterminate={ + !isEveryOptionSelected && totalOptionsSelected > 0 + } + /> diff --git a/app/src/organisms/Devices/RobotSettings/AdvancedTab/AdvancedTabSlideouts/__tests__/DeviceResetModal.test.tsx b/app/src/organisms/Devices/RobotSettings/AdvancedTab/AdvancedTabSlideouts/__tests__/DeviceResetModal.test.tsx index c5ba03f44b3..d40cb759706 100644 --- a/app/src/organisms/Devices/RobotSettings/AdvancedTab/AdvancedTabSlideouts/__tests__/DeviceResetModal.test.tsx +++ b/app/src/organisms/Devices/RobotSettings/AdvancedTab/AdvancedTabSlideouts/__tests__/DeviceResetModal.test.tsx @@ -5,11 +5,11 @@ import { renderWithProviders } from '@opentrons/components' import { i18n } from '../../../../../../i18n' import { resetConfig } from '../../../../../../redux/robot-admin' import { useDispatchApiRequest } from '../../../../../../redux/robot-api' - import { DeviceResetModal } from '../DeviceResetModal' import type { DispatchApiRequestType } from '../../../../../../redux/robot-api' +jest.mock('../../../../hooks') jest.mock('../../../../../../redux/robot-admin') jest.mock('../../../../../../redux/robot-api') diff --git a/app/src/organisms/Devices/RobotSettings/AdvancedTab/AdvancedTabSlideouts/__tests__/DeviceResetSlideout.test.tsx b/app/src/organisms/Devices/RobotSettings/AdvancedTab/AdvancedTabSlideouts/__tests__/DeviceResetSlideout.test.tsx index 036cadae2f2..bb4891a0733 100644 --- a/app/src/organisms/Devices/RobotSettings/AdvancedTab/AdvancedTabSlideouts/__tests__/DeviceResetSlideout.test.tsx +++ b/app/src/organisms/Devices/RobotSettings/AdvancedTab/AdvancedTabSlideouts/__tests__/DeviceResetSlideout.test.tsx @@ -106,20 +106,10 @@ describe('RobotSettings DeviceResetSlideout', () => { it('should change some options and text for Flex', () => { mockUseIsOT3.mockReturnValue(true) const [{ getByText, getByRole, queryByRole, queryByText }] = render() - getByText('Factory Reset') + getByText('Clear all data') getByText( 'Resets all settings. You’ll have to redo initial setup before using the robot again.' ) - getByText('Clear all stored data') - getByText( - 'Clears instrument calibrations and protocols. Keeps robot name and network settings.' - ) - - expect( - queryByText( - 'Resetting Deck and/or Tip Length Calibration data will also clear Pipette Offset Calibration data.' - ) - ).toBeNull() expect(queryByText('Clear deck calibration')).toBeNull() getByText('Clear pipette calibration(s)') expect(queryByText('Clear tip length calibrations')).toBeNull() diff --git a/app/src/organisms/RobotSettingsDashboard/DeviceReset.tsx b/app/src/organisms/RobotSettingsDashboard/DeviceReset.tsx index 78cfe9dc1c9..7c9f7b194f5 100644 --- a/app/src/organisms/RobotSettingsDashboard/DeviceReset.tsx +++ b/app/src/organisms/RobotSettingsDashboard/DeviceReset.tsx @@ -65,15 +65,35 @@ export function DeviceReset({ 'runsHistory', 'bootScripts', ] - const availableOptions = options.sort( - (a, b) => - targetOptionsOrder.indexOf(a.id) - targetOptionsOrder.indexOf(b.id) - ) + const availableOptions = options + // filtering out ODD setting because this gets implicitly cleared if all settings are selected + .filter(o => o.id !== 'onDeviceDisplay') + .sort( + (a, b) => + targetOptionsOrder.indexOf(a.id) - targetOptionsOrder.indexOf(b.id) + ) const dispatch = useDispatch() const handleClick = (): void => { if (resetOptions != null) { - dispatchRequest(resetConfig(robotName, resetOptions)) + const totalOptionsSelected = Object.values(resetOptions).filter( + selected => selected === true + ).length + + const isEveryOptionSelected = + totalOptionsSelected > 0 && + totalOptionsSelected === availableOptions.length + + if (isEveryOptionSelected) { + dispatchRequest( + resetConfig(robotName, { + ...resetOptions, + onDeviceDisplay: true, + }) + ) + } else { + dispatchRequest(resetConfig(robotName, resetOptions)) + } } } diff --git a/robot-server/robot_server/persistence/_persistence_directory.py b/robot-server/robot_server/persistence/_persistence_directory.py index dd31d5191a8..111e428b92d 100644 --- a/robot-server/robot_server/persistence/_persistence_directory.py +++ b/robot-server/robot_server/persistence/_persistence_directory.py @@ -12,7 +12,8 @@ _RESET_MARKER_FILE_NAME: Final = "_TO_BE_DELETED_ON_REBOOT" _RESET_MARKER_FILE_CONTENTS: Final = """\ This file was placed here by robot-server. -It tells robot-server to clear this directory on the next boot. +It tells robot-server to clear this directory on the next boot, +after which it will delete this file. """ diff --git a/robot-server/robot_server/service/legacy/reset_odd.py b/robot-server/robot_server/service/legacy/reset_odd.py new file mode 100644 index 00000000000..8176662dcb9 --- /dev/null +++ b/robot-server/robot_server/service/legacy/reset_odd.py @@ -0,0 +1,42 @@ +from typing_extensions import Final + +from anyio import Path as AsyncPath + + +# The on-device display process knows to look for this file here. +_RESET_MARKER_PATH: Final = AsyncPath("/data/ODD/_CONFIG_TO_BE_DELETED_ON_REBOOT") + +_RESET_MARKER_FILE_CONTENTS: Final = """\ +This file was placed here by robot-server. +It tells the on-device display process to clear its configuration on the next boot, +after which it will delete this file. +""" + + +async def mark_odd_for_reset_next_boot( + reset_marker_path: AsyncPath = _RESET_MARKER_PATH, +) -> None: + """Mark the configuration of the Flex's on-device display so it gets reset on the next boot. + + We can't just delete the config files from this Python process, because the on-device display + process actively reads and writes to them, so it would introduce race conditions. + We could `systemctl stop` the other process to fix that, but that would introduce other + problems: the factory-reset flow involves a separate HTTP request to reboot the machine, + and if we stopped the other process, it wouldn't be around to send it. + + This implementation assumes you're running on a real Flex, as opposed to an OT-2 or a + local dev machine. + + Params: + reset_marker_path: The path of the marker file to create. This should only be overridden + for unit-testing this function. + """ + try: + await reset_marker_path.write_text( + encoding="utf-8", data=_RESET_MARKER_FILE_CONTENTS + ) + except FileNotFoundError: + # No-op if the parent directory doesn't exist. + # This probably means the on-device display hasn't started up for the + # first time, or it's already been reset. + pass diff --git a/robot-server/robot_server/service/legacy/routers/settings.py b/robot-server/robot_server/service/legacy/routers/settings.py index 994992b9c5e..26ad778e81e 100644 --- a/robot-server/robot_server/service/legacy/routers/settings.py +++ b/robot-server/robot_server/service/legacy/routers/settings.py @@ -19,6 +19,7 @@ from robot_server.errors import LegacyErrorResponse from robot_server.hardware import get_hardware, get_robot_type +from robot_server.service.legacy import reset_odd from robot_server.service.legacy.models import V1BasicResponse from robot_server.service.legacy.models.settings import ( AdvancedSettingsResponse, @@ -232,6 +233,9 @@ async def post_settings_reset_options( if factory_reset_commands.get(reset_util.ResetOptionId.runs_history, False): await persistence_resetter.mark_directory_reset() + if factory_reset_commands.get(reset_util.ResetOptionId.on_device_display, False): + await reset_odd.mark_odd_for_reset_next_boot() + # TODO (tz, 5-24-22): The order of a set is undefined because set's aren't ordered. # The message returned to the client will be printed in the wrong order. message = ( @@ -307,7 +311,6 @@ async def get_pipette_setting(pipette_id: str) -> PipetteSettings: async def patch_pipette_setting( pipette_id: str, settings_update: PipetteSettingsUpdate ) -> PipetteSettings: - # Convert fields to dict of field name to value fields = settings_update.setting_fields or {} field_values = {k: None if v is None else v.value for k, v in fields.items()} diff --git a/robot-server/tests/integration/test_settings_reset_options_flex.tavern.yaml b/robot-server/tests/integration/test_settings_reset_options_flex.tavern.yaml index de24485d339..f39310d6b93 100644 --- a/robot-server/tests/integration/test_settings_reset_options_flex.tavern.yaml +++ b/robot-server/tests/integration/test_settings_reset_options_flex.tavern.yaml @@ -23,7 +23,11 @@ stages: description: !re_search 'Clear gripper offset calibrations' - id: runsHistory name: Clear Runs History - description: !re_search 'Erase this device''s stored history of protocols and runs.' + description: !re_search 'Erase this device''s stored history of protocols and runs' + - id: onDeviceDisplay + name: On-Device Display Configuration + description: !re_search 'on-device display' + - name: POST Reset gripperOffsetCalibrations true on OT-3 request: url: '{ot3_server_base_url}/settings/reset' @@ -34,3 +38,10 @@ stages: status_code: 200 json: message: "Options 'gripper_offset' were reset" + + # POSTing bootScripts, pipetteOffsetCalibrations, and runsHistory are untested here because they + # should already be covered by the OT-2 test. + + # POSTing onDeviceDisplay is untested here because it writes to a part of the filesystem outside + # of robot-server's control, which is not a good thing to do on a dev machine. + # We rely on manual end-to-end tests (with the Opentrons App) to cover it. diff --git a/robot-server/tests/service/legacy/routers/test_settings.py b/robot-server/tests/service/legacy/routers/test_settings.py index 1d6f15b80cb..65acd3978aa 100644 --- a/robot-server/tests/service/legacy/routers/test_settings.py +++ b/robot-server/tests/service/legacy/routers/test_settings.py @@ -485,6 +485,8 @@ async def mock_get_persistence_resetter() -> PersistenceResetter: "tipLengthCalibrations": True, "deckCalibration": True, "runsHistory": True, + # TODO(mm, 2023-08-04): Figure out how to test Flex-only options, + # then add gripperOffsetCalibrations and onDeviceDisplay. }, { ResetOptionId.boot_scripts, diff --git a/robot-server/tests/service/legacy/test_reset_odd.py b/robot-server/tests/service/legacy/test_reset_odd.py new file mode 100644 index 00000000000..d6441015ef0 --- /dev/null +++ b/robot-server/tests/service/legacy/test_reset_odd.py @@ -0,0 +1,17 @@ +from pathlib import Path + +from anyio import Path as AsyncPath + +from robot_server.service.legacy import reset_odd as subject + + +async def test_places_marker_file(tmp_path: Path) -> None: + reset_marker_path = AsyncPath(tmp_path / "foo") + await subject.mark_odd_for_reset_next_boot(reset_marker_path=reset_marker_path) + assert await reset_marker_path.exists() + + +async def test_noops_if_parent_dir_does_not_exist(tmp_path: Path) -> None: + await subject.mark_odd_for_reset_next_boot( + reset_marker_path=AsyncPath(tmp_path / "does_not_exist" / "foo") + )