diff --git a/robot-server/robot_server/service/app.py b/robot-server/robot_server/service/app.py index 2069bb9decc..186dc0aed54 100644 --- a/robot-server/robot_server/service/app.py +++ b/robot-server/robot_server/service/app.py @@ -24,6 +24,7 @@ from robot_server.service.session.router import router as session_router from robot_server.service.labware.router import router as labware_router from robot_server.service.protocol.router import router as protocol_router +from robot_server.service.system.router import router as system_router log = logging.getLogger(__name__) @@ -67,6 +68,9 @@ } }) +app.include_router(router=system_router, + tags=["System Control"]) + @app.on_event("startup") async def on_startup(): diff --git a/robot-server/robot_server/service/system/__init__.py b/robot-server/robot_server/service/system/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/robot-server/robot_server/service/system/models.py b/robot-server/robot_server/service/system/models.py new file mode 100644 index 00000000000..c09f9f7cca9 --- /dev/null +++ b/robot-server/robot_server/service/system/models.py @@ -0,0 +1,16 @@ +from datetime import datetime +from pydantic import BaseModel + +from robot_server.service.json_api import ResponseModel, ResponseDataModel, \ + RequestDataModel, RequestModel + + +class SystemTimeAttributes(BaseModel): + systemTime: datetime + + +SystemTimeResponseDataModel = ResponseDataModel[SystemTimeAttributes] + +SystemTimeResponse = ResponseModel[SystemTimeResponseDataModel, dict] + +SystemTimeRequest = RequestModel[RequestDataModel[SystemTimeAttributes]] diff --git a/robot-server/robot_server/service/system/router.py b/robot-server/robot_server/service/system/router.py new file mode 100644 index 00000000000..d6e623acb2b --- /dev/null +++ b/robot-server/robot_server/service/system/router.py @@ -0,0 +1,60 @@ +import logging +from datetime import datetime +from fastapi import APIRouter +from robot_server.system import time +from typing import Dict +from robot_server.service.system import models as time_models +from robot_server.service.json_api import ResourceLink + +router = APIRouter() +log = logging.getLogger(__name__) + +""" +These routes allows the client to read & update robot system time +""" + + +def _create_response(dt: datetime) \ + -> time_models.SystemTimeResponse: + """Create a SystemTimeResponse with system datetime""" + return time_models.SystemTimeResponse( + data=time_models.SystemTimeResponseDataModel.create( + attributes=time_models.SystemTimeAttributes( + systemTime=dt + ), + resource_id="time" + ), + links=_get_valid_time_links(router) + ) + + +def _get_valid_time_links(api_router: APIRouter) -> Dict[str, ResourceLink]: + """ Get valid links for time resource""" + return { + "GET": ResourceLink(href=api_router.url_path_for( + get_time.__name__)), + "PUT": ResourceLink(href=api_router.url_path_for( + set_time.__name__)) + } + + +@router.get("/system/time", + description="Fetch system time & date", + summary="Get robot's time status, which includes- current UTC " + "date & time, local timezone, whether robot time is synced" + " with an NTP server &/or it has an active RTC.", + response_model=time_models.SystemTimeResponse + ) +async def get_time() -> time_models.SystemTimeResponse: + res = await time.get_system_time() + return _create_response(res) + + +@router.put("/system/time", + description="Update system time", + summary="Set robot time", + response_model=time_models.SystemTimeResponse) +async def set_time(new_time: time_models.SystemTimeRequest) \ + -> time_models.SystemTimeResponse: + sys_time = await time.set_system_time(new_time.data.attributes.systemTime) + return _create_response(sys_time) diff --git a/robot-server/robot_server/system/__init__.py b/robot-server/robot_server/system/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/robot-server/robot_server/system/errors.py b/robot-server/robot_server/system/errors.py new file mode 100644 index 00000000000..5d396da5d20 --- /dev/null +++ b/robot-server/robot_server/system/errors.py @@ -0,0 +1,23 @@ +from robot_server.service.errors import RobotServerError, CommonErrorDef + + +class SystemException(RobotServerError): + """Base of all system exceptions""" + pass + + +class SystemTimeAlreadySynchronized(SystemException): + """ + Cannot update system time because it is already being synchronized + via NTP or local RTC. + """ + def __init__(self, msg: str): + super().__init__(definition=CommonErrorDef.ACTION_FORBIDDEN, + reason=msg) + + +class SystemSetTimeException(SystemException): + """Server process Failure""" + def __init__(self, msg: str): + super().__init__(definition=CommonErrorDef.INTERNAL_SERVER_ERROR, + error=msg) diff --git a/robot-server/robot_server/system/time.py b/robot-server/robot_server/system/time.py new file mode 100644 index 00000000000..ddd3fd701f4 --- /dev/null +++ b/robot-server/robot_server/system/time.py @@ -0,0 +1,88 @@ +import asyncio +import logging +from typing import Dict, Tuple, Union +from datetime import datetime, timezone +from opentrons.util.helpers import utc_now +from robot_server.system import errors + +log = logging.getLogger(__name__) + + +def _str_to_dict(res_str) -> Dict[str, Union[str, bool]]: + res_lines = res_str.splitlines() + res_dict = {} + + for line in res_lines: + if line: + try: + prop, val = line.split('=') + res_dict[prop] = val if val not in ['yes', 'no'] \ + else val == 'yes' # Convert yes/no to boolean value + except (ValueError, IndexError) as e: + log.error(f'Error converting timedatectl status line {line}:' + f' {e}') + return res_dict + + +async def _time_status(loop: asyncio.AbstractEventLoop = None + ) -> Dict[str, Union[str, bool]]: + """ + Get details of robot's date & time, with specifics of RTC (if present) + & status of NTP synchronization. + :return: Dictionary of status params. + """ + proc = await asyncio.create_subprocess_shell( + 'timedatectl show', + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + loop=loop or asyncio.get_event_loop() + ) + out, err = await proc.communicate() + return _str_to_dict(out.decode()) + + +async def _set_time(time: str, + loop: asyncio.AbstractEventLoop = None) -> Tuple[str, str]: + """ + :return: tuple of output of date --set (usually the new date) + & error, if any. + """ + proc = await asyncio.create_subprocess_shell( + f'date --utc --set \"{time}\"', + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + loop=loop or asyncio.get_event_loop() + ) + out, err = await proc.communicate() + return out.decode(), err.decode() + + +async def get_system_time(loop: asyncio.AbstractEventLoop = None) -> datetime: + """ + :return: Just the system time as a UTC datetime object. + """ + return utc_now() + + +async def set_system_time(new_time_dt: datetime, + loop: asyncio.AbstractEventLoop = None + ) -> datetime: + """ + Set the system time unless system time is already being synchronized using + an RTC or NTPsync. + Raise error with message, if any. + :return: current date read. + """ + status = await _time_status(loop) + if status.get('LocalRTC') is True or status.get('NTPSynchronized') is True: + # TODO: Update this to handle RTC sync correctly once we introduce RTC + raise errors.SystemTimeAlreadySynchronized( + 'Cannot set system time; already synchronized with NTP or RTC') + else: + new_time_dt = new_time_dt.astimezone(tz=timezone.utc) + new_time_str = new_time_dt.strftime("%Y-%m-%d %H:%M:%S") + log.info(f'Setting time to {new_time_str} UTC') + _, err = await _set_time(new_time_str) + if err: + raise errors.SystemSetTimeException(err) + return utc_now() diff --git a/robot-server/tests/integration/system/test_system_time.tavern.yaml b/robot-server/tests/integration/system/test_system_time.tavern.yaml new file mode 100644 index 00000000000..73aec4e4777 --- /dev/null +++ b/robot-server/tests/integration/system/test_system_time.tavern.yaml @@ -0,0 +1,51 @@ +--- +test_name: GET Time +marks: + - usefixtures: + - run_server +stages: + - name: System Time GET request returns time in correct format + request: + url: "{host:s}:{port:d}/system/time" + method: GET + response: + status_code: 200 + json: + data: + attributes: + systemTime: !re_search "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+\\+\\d{2}:\\d{2}$" + id: 'time' + type: 'SystemTimeAttributes' + links: + GET: + href: "/system/time" + meta: null + PUT: + href: "/system/time" + meta: null + meta: null + +--- +test_name: PUT Time +marks: + - usefixtures: + - run_server +stages: + - name: System Time PUT request without a time returns a missing field error + request: + url: "{host:s}:{port:d}/system/time" + method: PUT + json: + data: + id: "time" + type: "SystemTimeAttributes" + attributes: {} + response: + status_code: 422 + json: + errors: + - status: "422" + title: "value_error.missing" + detail: "field required" + source: + pointer: "/body/new_time/data/attributes/systemTime" diff --git a/robot-server/tests/service/system/__init__.py b/robot-server/tests/service/system/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/robot-server/tests/service/system/test_router.py b/robot-server/tests/service/system/test_router.py new file mode 100644 index 00000000000..948f3005f18 --- /dev/null +++ b/robot-server/tests/service/system/test_router.py @@ -0,0 +1,98 @@ +import pytest +from unittest.mock import patch +from datetime import datetime, timezone +from robot_server.system import time, errors + + +@pytest.fixture +def mock_system_time(): + return datetime(2020, 8, 14, 21, 44, 16, tzinfo=timezone.utc) + + +@pytest.fixture +def mock_set_system_time(mock_system_time): + with patch.object(time, 'set_system_time') as p: + yield p + + +@pytest.fixture +def response_links(): + return { + "GET": { + "href": "/system/time", + "meta": None + }, + "PUT": { + "href": "/system/time", + "meta": None + } + } + + +def test_raise_system_synchronized_error(api_client, + mock_system_time, + mock_set_system_time): + mock_set_system_time.side_effect = errors.SystemTimeAlreadySynchronized( + 'Cannot set system time; already synchronized with NTP or RTC') + + response = api_client.put("/system/time", json={ + "data": { + "id": "time", + "type": "SystemTimeAttributes", + "attributes": {"systemTime": mock_system_time.isoformat()} + } + }) + assert response.json() == {'errors': [{ + 'detail': 'Cannot set system time; already synchronized with NTP ' + 'or RTC', + 'status': '403', + 'title': 'Action Forbidden'}]} + assert response.status_code == 403 + + +def test_raise_system_exception(api_client, + mock_system_time, + mock_set_system_time): + mock_set_system_time.side_effect = errors.SystemSetTimeException( + 'Something went wrong') + + response = api_client.put("/system/time", json={ + "data": { + "id": "time", + "type": "SystemTimeAttributes", + "attributes": {"systemTime": mock_system_time.isoformat()} + } + }) + assert response.json() == {'errors': [{ + 'detail': 'Something went wrong', + 'status': '500', + 'title': 'Internal Server Error'}]} + assert response.status_code == 500 + + +def test_set_system_time(api_client, mock_system_time, + mock_set_system_time, response_links): + async def mock_side_effect(*args, **kwargs): + return mock_system_time + + mock_set_system_time.side_effect = mock_side_effect + + # Correct request + response = api_client.put("/system/time", + json={ + 'data': { + 'attributes': { + 'systemTime': + mock_system_time.isoformat()}, + 'id': 'time', + 'type': 'SystemTimeAttributes'}, + }) + assert response.json() == { + 'data': { + 'attributes': {'systemTime': mock_system_time.isoformat()}, + 'id': 'time', + 'type': 'SystemTimeAttributes'}, + 'links': response_links, + 'meta': None + } + assert response.status_code == 200 diff --git a/robot-server/tests/system/__init__.py b/robot-server/tests/system/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/robot-server/tests/system/test_time.py b/robot-server/tests/system/test_time.py new file mode 100644 index 00000000000..d72b40946b8 --- /dev/null +++ b/robot-server/tests/system/test_time.py @@ -0,0 +1,98 @@ +import pytest +from unittest.mock import MagicMock +from datetime import datetime, timezone +from robot_server.system import time +from robot_server.system import errors + + +@pytest.fixture +def mock_status_str(): + return "Timezone=Etc/UTC\n" \ + "LocalRTC=no\n" \ + "CanNTP=yes\n" \ + "NTP=yes\n" \ + "NTPSynchronized=no\n" \ + "TimeUSec=Fri 2020-08-14 21:44:16 UTC\n" + + +@pytest.fixture +def mock_status_dict(): + return {'Timezone': 'Etc/UTC', + 'LocalRTC': False, + 'CanNTP': True, + 'NTP': True, + 'NTPSynchronized': False, + 'TimeUSec': 'Fri 2020-08-14 21:44:16 UTC'} + + +@pytest.fixture +def mock_time(): + # The above time in datetime + return datetime(2020, 8, 14, 21, 44, 16, tzinfo=timezone.utc) + + +def test_str_to_dict(mock_status_str, mock_status_dict): + status_dict = time._str_to_dict(mock_status_str) + assert status_dict == mock_status_dict + + +@pytest.mark.parametrize( + argnames=["mock_status_err_str"], + argvalues=[[""], ["There is no equal sign"], ["=== Too many ==="]]) +def test_str_to_dict_does_not_raise_error(mock_status_err_str): + res_dict = time._str_to_dict(mock_status_err_str) + assert res_dict == {} + + +async def test_set_time_synchronized_error_response(mock_status_dict): + + async def async_mock_time_status(*args, **kwargs): + _stat = mock_status_dict + _stat.update(NTPSynchronized=True) + return _stat + + time._time_status = MagicMock(side_effect=async_mock_time_status) + + with pytest.raises(errors.SystemTimeAlreadySynchronized): + await time.set_system_time(datetime.now()) + + +async def test_set_time_general_error_response(mock_status_dict): + + async def async_mock_time_status(*args, **kwargs): + _stat = mock_status_dict + _stat.update(NTPSynchronized=False) + return _stat + + async def async_mock_set_time(*args, **kwargs): + return "out", "An error occurred" + + time._time_status = MagicMock(side_effect=async_mock_time_status) + time._set_time = MagicMock(side_effect=async_mock_set_time) + + with pytest.raises(errors.SystemSetTimeException): + await time.set_system_time(datetime.now()) + + +async def test_set_time_response(mock_status_dict, mock_time): + + async def async_mock_time_status(*args, **kwargs): + _stat = mock_status_dict + _stat.update(NTPSynchronized=False) + return _stat + + async def async_mock_set_time(*args, **kwargs): + return "out", "" + + time._time_status = MagicMock(side_effect=async_mock_time_status) + time._set_time = MagicMock(side_effect=async_mock_set_time) + + # System time gets set successfully + time._set_time.assert_not_called() + await time.set_system_time(mock_time) + time._set_time.assert_called_once() + + # Datetime is converted to special format with UTC timezone for _set_time + await time.set_system_time(datetime.fromisoformat( + "2020-08-14T16:44:16-05:00")) # from EST + time._set_time.assert_called_with("2020-08-14 21:44:16") # to UTC