From 83e60c24941396e7ca2735f5ce67821142d1700d Mon Sep 17 00:00:00 2001 From: Benjamin Morris Date: Fri, 29 Jun 2018 14:55:16 -0400 Subject: [PATCH] feat(api): Add advanced settings endpoints to api server Fixes #1656 --- api/opentrons/config/advanced_settings.py | 127 ++++++++++++++++++ api/opentrons/config/feature_flags.py | 64 ++------- api/opentrons/server/endpoints/__init__.py | 41 +++++- .../server/endpoints/serverlib_fallback.py | 3 +- api/opentrons/server/main.py | 6 +- .../server/test_settings_endpoints.py | 36 +++++ 6 files changed, 218 insertions(+), 59 deletions(-) create mode 100644 api/opentrons/config/advanced_settings.py create mode 100644 api/tests/opentrons/server/test_settings_endpoints.py diff --git a/api/opentrons/config/advanced_settings.py b/api/opentrons/config/advanced_settings.py new file mode 100644 index 000000000000..5bf0ab574afb --- /dev/null +++ b/api/opentrons/config/advanced_settings.py @@ -0,0 +1,127 @@ +import json +import logging +from copy import copy +from opentrons.config import get_config_index + +log = logging.getLogger(__name__) + + +class Setting: + def __init__(self, _id, title, description, old_id=None): + self.id = _id + self.old_id = old_id + self.title = title + self.description = description + + def __repr__(self): + return '{}: {}'.format(self.__class__, self.id) + + +settings = [ + Setting( + _id='shortFixedTrash', + old_id='short-fixed-trash', + title='Short (55mm) fixed trash', + description='Trash box is 55mm tall (rather than the 77mm default)' + ), + Setting( + _id='splitLabwareDefinitions', + old_id='split-labware-def', + title='New JSON labware definitions', + description='JSON labware definitions with a separate def file and' + ' offset file for each labware' + ), + Setting( + _id='calibrateToBottom', + old_id='calibrate-to-bottom', + title='Calibrate to bottom', + description='Calibrate using the bottom-center of well A1 for each' + ' labware (rather than the top-center)' + ), + Setting( + _id='deckCalibrationDots', + old_id='dots-deck-type', + title='Deck calibration to dots', + description='Perform deck calibration to dots rather than crosses, for' + ' robots that do not have crosses etched on the deck' + ), + Setting( + _id='disableHomeOnBoot', + old_id='disable-home-on-boot', + title='Disable home on boot', + description='Prevent robot from homing motors on boot' + ) +] + +settings_by_id = {s.id: s for s in settings} +settings_by_old_id = {s.old_id: s for s in settings} + + +def get_adv_setting(_id: str) -> bool: + _id = _clean_id(_id) + s = get_all_adv_settings() + return bool(s.get(_id)) + + +def get_all_adv_settings() -> dict: + settings_file = get_config_index().get('featureFlagFile') + + values = _read_settings_file(settings_file) + for key, value in values.items(): + s = copy(settings_by_id[key].__dict__) + s.pop('old_id') + values[key] = s + values[key]['value'] = value + return values + + +def set_adv_setting(_id: str, value): + _id = _clean_id(_id) + settings_file = get_config_index().get('featureFlagFile') + s = _read_settings_file(settings_file) + s[_id] = value + _write_settings_file(s, settings_file) + + +def _clean_id(_id: str) -> str: + if _id in settings_by_old_id.keys(): + _id = settings_by_old_id[_id].id + return _id + + +def _read_json_file(path: str) -> dict: + try: + with open(path, 'r') as fd: + data = json.load(fd) + except FileNotFoundError: + data = {} + return data + + +def _read_settings_file(settings_file: str) -> dict: + # Read settings from persistent file + data = _read_json_file(settings_file) + all_ids = [s.id for s in settings] + + # If any old keys are stored in the file, replace them with the new key + old_keys = settings_by_old_id.keys() + if any([k in old_keys for k in data.keys()]): + for v in data.keys(): + if v in old_keys: + new_key = settings_by_old_id.get(v).id + data[new_key] = data[v] + data.pop(v) + _write_settings_file(data, settings_file) + + # If any settings do not have a key in the data, default to `False` + res = {key: data.get(key, False) for key in all_ids} + return res + + +def _write_settings_file(data: dict, settings_file: str): + try: + with open(settings_file, 'w') as fd: + json.dump(data, fd) + except OSError: + log.exception('Failed to write advanced settings file to: {}'.format( + settings_file)) diff --git a/api/opentrons/config/feature_flags.py b/api/opentrons/config/feature_flags.py index e0fb25e0a66e..b9485f0e3c90 100644 --- a/api/opentrons/config/feature_flags.py +++ b/api/opentrons/config/feature_flags.py @@ -1,63 +1,21 @@ -import os -import json -from opentrons.config import get_config_index +from opentrons.config import advanced_settings as advs -def get_feature_flag(name: str) -> bool: - settings = get_all_feature_flags() - return bool(settings.get(name)) +def short_fixed_trash(): + return advs.get_adv_setting('shortFixedTrash') -def get_all_feature_flags() -> dict: - settings_file = get_config_index().get('featureFlagFile') - if settings_file and os.path.exists(settings_file): - with open(settings_file, 'r') as fd: - settings = json.load(fd) - else: - settings = {} - return settings +def split_labware_definitions(): + return advs.get_adv_setting('splitLabwareDefinitions') -def set_feature_flag(name: str, value): - settings_file = get_config_index().get('featureFlagFile') - if os.path.exists(settings_file): - with open(settings_file, 'r') as fd: - settings = json.load(fd) - settings[name] = value - else: - settings = {name: value} - with open(settings_file, 'w') as fd: - json.dump(settings, fd) +def calibrate_to_bottom(): + return advs.get_adv_setting('calibrateToBottom') -# short_fixed_trash -# - True ('55.0'): Old (55mm tall) fixed trash -# - False: 77mm tall fixed trash -# - EOL: when all short fixed trash containers have been replaced -def short_fixed_trash(): return get_feature_flag('short-fixed-trash') +def dots_deck_type(): + return advs.get_adv_setting('deckCalibrationDots') -# split_labware_definitions -# - True: Use new labware definitions (See: labware_definitions.py and -# serializers.py) -# - False: Use sqlite db -def split_labware_definitions(): return get_feature_flag('split-labware-def') - - -# calibrate_to_bottom -# - True: You must calibrate your containers to bottom -# - False: Otherwise the default -# will be that you calibrate to the top -def calibrate_to_bottom(): return get_feature_flag('calibrate-to-bottom') - - -# dots_deck_type -# - True: The deck layout has etched "dots" -# - False: The deck layout has etched "crosses" -def dots_deck_type(): return get_feature_flag('dots-deck-type') - - -# disable_home_on_boot -# - True: The robot should not home the carriages on boot -# - False: The robot should home the carriages on boot -def disable_home_on_boot(): return get_feature_flag('disable-home-on-boot') +def disable_home_on_boot(): + return advs.get_adv_setting('disableHomeOnBoot') diff --git a/api/opentrons/server/endpoints/__init__.py b/api/opentrons/server/endpoints/__init__.py index bd351d626c3e..c08a3a63d31c 100644 --- a/api/opentrons/server/endpoints/__init__.py +++ b/api/opentrons/server/endpoints/__init__.py @@ -3,6 +3,8 @@ import logging from aiohttp import web from opentrons import robot, __version__ +from opentrons.config import advanced_settings as advs + log = logging.getLogger(__name__) @@ -12,7 +14,7 @@ os.environ.get('RESIN_DEVICE_NAME_AT_INIT', 'dev')) -async def health(request): +async def health(request: web.Request) -> web.Response: res = { 'name': NAME, 'api_version': __version__, @@ -21,3 +23,40 @@ async def health(request): return web.json_response( headers={'Access-Control-Allow-Origin': '*'}, body=json.dumps(res)) + + +async def get_advanced_settings(request: web.Request) -> web.Response: + """ + Handles a GET request and returns a json body with the key "settings" and a + value that is a list of objects where each object has keys "id", "title", + "description", and "value" + """ + res = _get_adv_settings() + return web.json_response(res) + + +def _get_adv_settings() -> dict: + data = advs.get_all_adv_settings() + return {"settings": list(data.values())} + + +async def set_advanced_setting(request: web.Request) -> web.Response: + """ + Handles a POST request with a json body that has keys "id" and "value", + where the value of "id" must correspond to an id field of a setting in + `opentrons.config.advanced_settings.settings`. Saves the value of "value" + for the setting that matches the supplied id. + """ + data = await request.json() + key = data.get('id') + value = data.get('value') + if key and key in advs.settings_by_id.keys(): + advs.set_adv_setting(key, value) + res = _get_adv_settings() + status = 200 + else: + res = _get_adv_settings() + res.update( + {'error': 'ID {} not found in advanced settings list'.format(key)}) + status = 400 + return web.json_response(res, status=status) diff --git a/api/opentrons/server/endpoints/serverlib_fallback.py b/api/opentrons/server/endpoints/serverlib_fallback.py index 5ad61c21aa69..a91e0c70ecb0 100644 --- a/api/opentrons/server/endpoints/serverlib_fallback.py +++ b/api/opentrons/server/endpoints/serverlib_fallback.py @@ -1,4 +1,5 @@ -# This file duplicates the implementation of ot2serverlib +# This file duplicates the implementation of ot2serverlib. Remove once all +# robots have new update endpoints import os import json import asyncio diff --git a/api/opentrons/server/main.py b/api/opentrons/server/main.py index dfdc067e502b..98d65f8e0bd7 100755 --- a/api/opentrons/server/main.py +++ b/api/opentrons/server/main.py @@ -185,11 +185,9 @@ def init(loop=None): server.app.router.add_post( '/robot/lights', control.set_rail_lights) server.app.router.add_get( - '/settings', update.get_feature_flag) - server.app.router.add_get( - '/settings/environment', update.environment) + '/settings', endp.get_advanced_settings) server.app.router.add_post( - '/settings/set', update.set_feature_flag) + '/settings', endp.set_advanced_setting) return server.app diff --git a/api/tests/opentrons/server/test_settings_endpoints.py b/api/tests/opentrons/server/test_settings_endpoints.py new file mode 100644 index 000000000000..6f7fa551bc65 --- /dev/null +++ b/api/tests/opentrons/server/test_settings_endpoints.py @@ -0,0 +1,36 @@ +from opentrons.server.main import init + + +def validate_response_body(body): + settings_list = body.get('settings') + assert type(settings_list) == list + for obj in settings_list: + assert 'id' in obj, '"id" field not found in settings object' + assert 'title' in obj, '"title" not found for {}'.format(obj['id']) + assert 'description' in obj, '"description" not found for {}'.format( + obj['id']) + assert 'value' in obj, '"value" not found for {}'.format(obj['id']) + + +async def test_get(virtual_smoothie_env, loop, test_client): + app = init(loop) + cli = await loop.create_task(test_client(app)) + + resp = await cli.get('/settings') + body = await resp.json() + assert resp.status == 200 + validate_response_body(body) + + +async def test_set(virtual_smoothie_env, loop, test_client): + app = init(loop) + cli = await loop.create_task(test_client(app)) + test_id = 'disableHomeOnBoot' + + resp = await cli.post('/settings', json={"id": test_id, "value": True}) + body = await resp.json() + assert resp.status == 200 + validate_response_body(body) + test_setting = list( + filter(lambda x: x.get('id') == test_id, body.get('settings')))[0] + assert test_setting.get('value')