From c13660c3f1e9c815f1e3eb0fca60cc71e851d8d1 Mon Sep 17 00:00:00 2001 From: Seth Foster Date: Wed, 3 Oct 2018 14:29:46 -0400 Subject: [PATCH] feat(api): Add modules api to hardware_control Closes #2237 --- api/opentrons/drivers/temp_deck/driver.py | 3 +- api/opentrons/hardware_control/__init__.py | 54 +++++- api/opentrons/hardware_control/controller.py | 23 ++- .../hardware_control/modules/__init__.py | 88 +++++++++ .../hardware_control/modules/magdeck.py | 137 ++++++++++++++ .../hardware_control/modules/mod_abc.py | 70 +++++++ .../hardware_control/modules/tempdeck.py | 176 ++++++++++++++++++ .../hardware_control/modules/update.py | 148 +++++++++++++++ api/opentrons/hardware_control/simulator.py | 28 ++- api/opentrons/legacy_api/modules/__init__.py | 6 +- api/opentrons/server/endpoints/update.py | 15 +- .../modules/test_hc_magdeck.py | 26 +++ .../modules/test_hc_tempdeck.py | 50 +++++ .../hardware_control/test_modules.py | 89 +++++++++ .../opentrons/hardware_control/test_moves.py | 8 +- .../opentrons/server/test_update_endpoints.py | 8 +- 16 files changed, 900 insertions(+), 29 deletions(-) create mode 100644 api/opentrons/hardware_control/modules/__init__.py create mode 100644 api/opentrons/hardware_control/modules/magdeck.py create mode 100644 api/opentrons/hardware_control/modules/mod_abc.py create mode 100644 api/opentrons/hardware_control/modules/tempdeck.py create mode 100644 api/opentrons/hardware_control/modules/update.py create mode 100644 api/tests/opentrons/hardware_control/modules/test_hc_magdeck.py create mode 100644 api/tests/opentrons/hardware_control/modules/test_hc_tempdeck.py create mode 100644 api/tests/opentrons/hardware_control/test_modules.py diff --git a/api/opentrons/drivers/temp_deck/driver.py b/api/opentrons/drivers/temp_deck/driver.py index e67827fcada2..798ddb541c6d 100644 --- a/api/opentrons/drivers/temp_deck/driver.py +++ b/api/opentrons/drivers/temp_deck/driver.py @@ -220,7 +220,8 @@ def update_temperature(self, default=None) -> str: try: self._update_thread = Thread( target=self._recursive_update_temperature, - args=[DEFAULT_COMMAND_RETRIES]) + args=[DEFAULT_COMMAND_RETRIES], + name='Tempdeck recursive update temperature') self._update_thread.start() except (TempDeckError, SerialException, SerialNoResponse) as e: return str(e) diff --git a/api/opentrons/hardware_control/__init__.py b/api/opentrons/hardware_control/__init__.py index d182aef151ed..aea7dd8ba3e3 100644 --- a/api/opentrons/hardware_control/__init__.py +++ b/api/opentrons/hardware_control/__init__.py @@ -14,7 +14,7 @@ import functools import logging import enum -from typing import Dict, Union +from typing import Any, Dict, Union, List, Optional, Tuple from opentrons import types from .simulator import Simulator try: @@ -22,6 +22,7 @@ except ModuleNotFoundError: # implies windows Controller = None # type: ignore +from . import modules mod_log = logging.getLogger(__name__) @@ -88,6 +89,7 @@ def __init__(self, self._attached_instruments = {types.Mount.LEFT: None, types.Mount.RIGHT: None} + self._attached_modules: Dict[str, Any] = {} @classmethod def build_hardware_controller( @@ -108,7 +110,8 @@ def build_hardware_controller( @classmethod def build_hardware_simulator( cls, - attached_instruments, + attached_instruments: Dict[types.Mount, Optional[str]] = None, + attached_modules: List[str] = None, config: dict = None, loop: asyncio.AbstractEventLoop = None) -> 'API': """ Build a simulating hardware controller. @@ -116,7 +119,14 @@ def build_hardware_simulator( This method may be used both on a real robot and on dev machines. Multiple simulating hardware controllers may be active at one time. """ - return cls(Simulator(attached_instruments, config, loop), + if None is attached_instruments: + attached_instruments = {types.Mount.LEFT: None, + types.Mount.RIGHT: None} + if None is attached_modules: + attached_modules = [] + return cls(Simulator(attached_instruments, + attached_modules, + config, loop), config=config, loop=loop) # Query API @@ -262,3 +272,41 @@ async def set_flow_rate(self, mount, aspirate=None, dispense=None): @_log_call async def set_pick_up_current(self, mount, amperes): pass + + @_log_call + async def discover_modules(self): + discovered = {port + model: (port, model) + for port, model in self._backend.get_attached_modules()} + these = set(discovered.keys()) + known = set(self._attached_modules.keys()) + new = these - known + gone = known - these + for mod in gone: + self._attached_modules.pop(mod) + for mod in new: + self._attached_modules[mod]\ + = self._backend.build_module(discovered[mod][0], + discovered[mod][1]) + return list(self._attached_modules.values()) + + @_log_call + async def update_module( + self, module: modules.AbstractModule, + firmware_file: str, + loop: asyncio.AbstractEventLoop = None) -> Tuple[bool, str]: + """ Update a module's firmware. + + Returns (ok, message) where ok is True if the update succeeded and + message is a human readable message. + """ + details = (module.port, module.name()) + mod = self._attached_modules.pop(details[0] + details[1]) + try: + new_mod = await self._backend.update_module( + mod, firmware_file, loop) + except modules.UpdateError as e: + return False, e.msg + else: + new_details = new_mod.port + new_mod.device_info['model'] + self._attached_modules[new_details] = new_mod + return True, 'firmware update successful' diff --git a/api/opentrons/hardware_control/controller.py b/api/opentrons/hardware_control/controller.py index a3ed19304d92..718e49ae76b7 100644 --- a/api/opentrons/hardware_control/controller.py +++ b/api/opentrons/hardware_control/controller.py @@ -1,10 +1,13 @@ +import asyncio import os import fcntl import threading -from typing import Dict +from typing import Dict, List, Optional, Tuple from opentrons.util import environment from opentrons.drivers.smoothie_drivers import driver_3_0 from opentrons.legacy_api.robot import robot_configs +from . import modules + _lock = threading.Lock() @@ -65,6 +68,7 @@ def __init__(self, config, loop): self.config = config or robot_configs.load() self._smoothie_driver = driver_3_0.SmoothieDriver_3_0_0( config=self.config) + self._attached_modules = {} def move(self, target_position: Dict[str, float], home_flagged_axes=True): self._smoothie_driver.move( @@ -75,3 +79,20 @@ def home(self): def get_attached_instruments(self, mount): return self._smoothie_driver.read_pipette_model(mount.name.lower()) + + def get_attached_modules(self) -> List[Tuple[str, str]]: + return modules.discover() + + return list(self._attached_modules.values()) + + def build_module(self, port: str, model: str) -> modules.AbstractModule: + return modules.build(port, model, False) + + async def update_module( + self, + module: modules.AbstractModule, + firmware_file: str, + loop: Optional[asyncio.AbstractEventLoop])\ + -> modules.AbstractModule: + return await modules.update_firmware( + module, firmware_file, loop) diff --git a/api/opentrons/hardware_control/modules/__init__.py b/api/opentrons/hardware_control/modules/__init__.py new file mode 100644 index 000000000000..3cc09e3f5e96 --- /dev/null +++ b/api/opentrons/hardware_control/modules/__init__.py @@ -0,0 +1,88 @@ +import asyncio +import logging +import os +import re +from typing import List, Optional, Tuple + +from .mod_abc import AbstractModule +# Must import tempdeck and magdeck (and other modules going forward) so they +# actually create the subclasses +from . import update, tempdeck, magdeck # noqa(W0611) + +log = logging.getLogger(__name__) + + +class UnsupportedModuleError(Exception): + pass + + +class AbsentModuleError(Exception): + pass + + +# mypy isn’t quite expressive enough to handle what we’re doing here, which +# is get all the class objects that are subclasses of an abstract module +# (strike 1) and call a classmethod on them (strike 2) and actually store +# the class objects (strike 3). So, type: ignore +MODULE_TYPES = {cls.name(): cls + for cls in AbstractModule.__subclasses__()} # type: ignore + + +def build(port: str, which: str, simulate: bool) -> AbstractModule: + return MODULE_TYPES[which].build(port, simulate) + + +def discover() -> List[Tuple[str, str]]: + """ Scan for connected modules and instantiate handler classes + """ + if os.environ.get('RUNNING_ON_PI') and os.path.isdir('/dev/modules'): + devices = os.listdir('/dev/modules') + else: + devices = [] + + discovered_modules = [] + + module_port_regex = re.compile('|'.join(MODULE_TYPES.keys()), re.I) + for port in devices: + match = module_port_regex.search(port) + if match: + name = match.group().lower() + if name not in MODULE_TYPES: + log.warning("Unexpected module connected: {} on {}" + .format(name, port)) + continue + absolute_port = '/dev/modules/{}'.format(port) + discovered_modules.append((absolute_port, name)) + log.info('Discovered modules: {}'.format(discovered_modules)) + + return discovered_modules + + +class UpdateError(RuntimeError): + def __init__(self, msg): + self.msg = msg + + +async def update_firmware( + module: AbstractModule, + firmware_file: str, + loop: Optional[asyncio.AbstractEventLoop]) -> AbstractModule: + """ Update a module. + + If the update succeeds, an Module instance will be returned. + + Otherwise, raises an UpdateError with the reason for the failure. + """ + simulated = module.is_simulated + cls = type(module) + old_port = module.port + flash_port = await module.prep_for_update() + del module + after_port, results = await update.update_firmware(flash_port, + firmware_file, + loop) + await asyncio.sleep(1.0) + new_port = after_port or old_port + if not results[0]: + raise UpdateError(results[1]) + return cls.build(new_port, simulated) diff --git a/api/opentrons/hardware_control/modules/magdeck.py b/api/opentrons/hardware_control/modules/magdeck.py new file mode 100644 index 000000000000..97be7f7f5d55 --- /dev/null +++ b/api/opentrons/hardware_control/modules/magdeck.py @@ -0,0 +1,137 @@ +from opentrons.drivers.mag_deck import MagDeck as MagDeckDriver +from . import update, mod_abc + +LABWARE_ENGAGE_HEIGHT = {'biorad-hardshell-96-PCR': 18} # mm +MAX_ENGAGE_HEIGHT = 45 # mm from home position + + +class MissingDevicePortError(Exception): + pass + + +class SimulatingDriver: + def __init__(self): + self._port = None + + def probe_plate(self): + pass + + def home(self): + pass + + def move(self, location): + pass + + def get_device_info(self): + return {'serial': 'dummySerial', + 'model': 'dummyModel', + 'version': 'dummyVersion'} + + def connect(self, port): + pass + + def disconnect(self): + pass + + def enter_programming_mode(self): + pass + + +class MagDeck(mod_abc.AbstractModule): + """ + Under development. API subject to change + """ + @classmethod + def build(cls, port, simulating=False): + mod = cls(port, simulating) + mod._connect() + return mod + + @classmethod + def name(cls) -> str: + return 'magdeck' + + def __init__(self, port, simulating): + self._engaged = False + self._port = port + if simulating: + self._driver = SimulatingDriver() + else: + self._driver = MagDeckDriver() + self._device_info = None + + def calibrate(self): + """ + Calibration involves probing for top plate to get the plate height + """ + self._driver.probe_plate() + # return if successful or not? + self._engaged = False + + def engage(self, height): + """ + Move the magnet to a specific height, in mm from home position + """ + if height > MAX_ENGAGE_HEIGHT or height < 0: + raise ValueError('Invalid engage height. Should be 0 to {}'.format( + MAX_ENGAGE_HEIGHT)) + self._driver.move(height) + self._engaged = True + + def disengage(self): + """ + Home the magnet + """ + self._driver.home() + self._engaged = False + + @property + def device_info(self): + """ + Returns a dict: + { 'serial': 'abc123', 'model': '8675309', 'version': '9001' } + """ + return self._device_info + + @property + def status(self): + return 'engaged' if self._engaged else 'disengaged' + + @property + def live_data(self): + return { + 'status': self.status, + 'data': {} + } + + @property + def port(self): + return self._port + + @property + def is_simulated(self): + return isinstance(self._driver, SimulatingDriver) + + # Internal Methods + + def _connect(self): + """ + Connect to the serial port + """ + self._driver.connect(self._port) + self._device_info = self._driver.get_device_info() + + def _disconnect(self): + """ + Disconnect from the serial port + """ + if self._driver: + self._driver.disconnect() + + def __del__(self): + self._disconnect() + + async def prep_for_update(self) -> str: + new_port = await update.enter_bootloader(self._driver, + self.device_info['model']) + return new_port or self.port diff --git a/api/opentrons/hardware_control/modules/mod_abc.py b/api/opentrons/hardware_control/modules/mod_abc.py new file mode 100644 index 000000000000..20ec2bedeb41 --- /dev/null +++ b/api/opentrons/hardware_control/modules/mod_abc.py @@ -0,0 +1,70 @@ +import abc +from typing import Dict + + +class AbstractModule(abc.ABC): + """ Defines the common methods of a module. """ + + @classmethod + @abc.abstractmethod + def build(cls, port: str, simulating: bool = False) -> 'AbstractModule': + """ Modules should always be created using this factory. + + This lets the (perhaps blocking) work of connecting to and initializing + a module be in a place that can be async. + """ + pass + + @abc.abstractmethod + def disengage(self): + """ Deactivate the module. """ + pass + + @property + @abc.abstractmethod + def status(self) -> str: + """ Return some string describing status. """ + pass + + @property + @abc.abstractmethod + def device_info(self) -> Dict[str, str]: + """ Return a dict of the module's static information (serial, etc)""" + pass + + @property + @abc.abstractmethod + def live_data(self) -> Dict[str, str]: + """ Return a dict of the module's dynamic information """ + pass + + @property + @abc.abstractmethod + def is_simulated(self) -> bool: + """ True if >this is a simulated module. """ + pass + + @property + @abc.abstractmethod + def port(self) -> str: + """ The port where the module is connected. """ + pass + + @abc.abstractmethod + async def prep_for_update(self) -> str: + """ Prepare for an update. + + By the time this coroutine completes, the hardware should be ready + to take an update. This implicitly tears down the module instance; + it does not need to be either working or recoverable after this + coroutine completes. + + :returns str: The port we're running on. + """ + pass + + @classmethod + @abc.abstractmethod + def name(cls) -> str: + """ A name for this kind of module. """ + pass diff --git a/api/opentrons/hardware_control/modules/tempdeck.py b/api/opentrons/hardware_control/modules/tempdeck.py new file mode 100644 index 000000000000..2f906d9599e1 --- /dev/null +++ b/api/opentrons/hardware_control/modules/tempdeck.py @@ -0,0 +1,176 @@ +import asyncio +from threading import Thread, Event +from opentrons.drivers.temp_deck import TempDeck as TempDeckDriver +from . import update, mod_abc + +TEMP_POLL_INTERVAL_SECS = 1 + + +class MissingDevicePortError(Exception): + pass + + +class SimulatingDriver: + def __init__(self): + self._target_temp = 0 + self._active = False + self._port = None + + def set_temperature(self, celsius): + self._target_temp = celsius + self._active = True + + def disengage(self): + self._target_temp = 0 + self._active = False + + def update_temperature(self): + pass + + def connect(self, port): + self._port = port + + def disconnect(self): + pass + + def enter_programming_mode(self): + pass + + @property + def temperature(self): + return self._target_temp + + @property + def target(self): + return self._target_temp + + @property + def status(self): + return 'holding at target' if self._active else 'idle' + + def get_device_info(self): + return {'serial': 'dummySerial', + 'model': 'dummyModel', + 'version': 'dummyVersion'} + + +class Poller(Thread): + def __init__(self, driver): + self._driver_ref = driver + self._stop_event = Event() + super().__init__(target=self._poll_temperature, + name='Temperature poller for tempdeck') + + def _poll_temperature(self): + while not self._stop_event.wait(TEMP_POLL_INTERVAL_SECS): + self._driver_ref.update_temperature() + + def join(self): + self._stop_event.set() + super().join() + + +class TempDeck(mod_abc.AbstractModule): + """ + Under development. API subject to change without a version bump + """ + @classmethod + def build(cls, port, simulating=False): + """ Build and connect to a TempDeck. + """ + mod = cls(port, simulating) + mod._connect() + return mod + + @classmethod + def name(cls) -> str: + return 'tempdeck' + + def __init__(self, port, simulating): + if simulating: + self._driver = SimulatingDriver() + else: + self._driver = TempDeckDriver() + self._port = port + self._device_info = None + self._poller = None + + def set_temperature(self, celsius): + """ + Set temperature in degree Celsius + Range: 4 to 95 degree Celsius (QA tested). + The internal temp range is -9 to 99 C, which is limited by the 2-digit + temperature display. Any input outside of this range will be clipped + to the nearest limit + """ + return self._driver.set_temperature(celsius) + + def disengage(self): + """ Stop heating/cooling and turn off the fan """ + self._driver.disengage() + + async def wait_for_temp(self): + """ + This method exits only if set temperature has reached.Subject to change + """ + while self.status != 'holding at target': + await asyncio.sleep(0.1) + + @property + def device_info(self): + return self._device_info + + @property + def live_data(self): + return { + 'status': self.status, + 'data': { + 'currentTemp': self.temperature, + 'targetTemp': self.target + } + } + + @property + def temperature(self): + return self._driver.temperature + + @property + def target(self): + return self._driver.target + + @property + def status(self): + return self._driver.status + + @property + def port(self): + return self._port + + @property + def is_simulated(self): + return isinstance(self._driver, SimulatingDriver) + + def _connect(self): + """ + Connect to the 'TempDeck' port + Planned change- will connect to the correct port in case of multiple + TempDecks + """ + if self._poller: + self._poller.join() + self._driver.connect(self._port) + self._device_info = self._driver.get_device_info() + self._poller = Poller(self._driver) + self._poller.start() + + def __del__(self): + if hasattr(self, '_poller') and self._poller: + self._poller.join() + + async def prep_for_update(self) -> str: + self._poller.join() + del self._poller + self._poller = None + new_port = await update.enter_bootloader(self._driver, + self.name()) + return new_port or self.port diff --git a/api/opentrons/hardware_control/modules/update.py b/api/opentrons/hardware_control/modules/update.py new file mode 100644 index 000000000000..b62d624ba580 --- /dev/null +++ b/api/opentrons/hardware_control/modules/update.py @@ -0,0 +1,148 @@ +import asyncio +import logging +import os +from typing import Any, Dict, Optional, Tuple +from opentrons import HERE as package_root + +log = logging.getLogger(__name__) + +PORT_SEARCH_TIMEOUT = 5.5 + +# avrdude_options +PART_NO = 'atmega32u4' +PROGRAMMER_ID = 'avr109' +BAUDRATE = '57600' + + +async def enter_bootloader(driver, model): + """ + Using the driver method, enter bootloader mode of the atmega32u4. + The bootloader mode opens a new port on the uC to upload the hex file. + After receiving a 'dfu' command, the firmware provides a 3-second window to + close the current port so as to do a clean switch to the bootloader port. + The new port shows up as 'ttyn_bootloader' on the pi; upload fw through it. + NOTE: Modules with old bootloader will have the bootloader port show up as + a regular module port- 'ttyn_tempdeck'/ 'ttyn_magdeck' with the port number + being either different or same as the one that the module was originally on + So we check for changes in ports and use the appropriate one + """ + # Required for old bootloader + ports_before_dfu_mode = await _discover_ports() + + driver.enter_programming_mode() + driver.disconnect() + new_port = '' + try: + new_port = await asyncio.wait_for( + _port_poll(_has_old_bootloader(model), ports_before_dfu_mode), + PORT_SEARCH_TIMEOUT) + except asyncio.TimeoutError: + pass + return new_port + + +async def update_firmware(port: str, + firmware_file_path: str, + loop: Optional[asyncio.AbstractEventLoop])\ + -> Tuple[str, Tuple[bool, str]]: + """ + Run avrdude firmware upload command. Switch back to normal module port + + Note: For modules with old bootloader, the kernel could assign the module + a new port after the update (since the board is automatically reset). + Scan for such a port change and use the appropriate port. + + Returns a tuple of the new port to communicate on (or None if it was not + found) and a tuple of success and message from avrdude. + """ + + ports_before_update = await _discover_ports() + config_file_path = os.path.join(package_root, + 'config', 'modules', 'avrdude.conf') + kwargs: Dict[str, Any] = { + 'stdout': asyncio.subprocess.PIPE, + 'stderr': asyncio.subprocess.PIPE + } + if loop: + kwargs['loop'] = loop + proc = await asyncio.create_subprocess_exec( + 'avrdude', '-C{}'.format(config_file_path), '-v', + '-p{}'.format(PART_NO), + '-c{}'.format(PROGRAMMER_ID), + '-P{}'.format(port), + '-b{}'.format(BAUDRATE), '-D', + '-Uflash:w:{}:i'.format(firmware_file_path), + **kwargs) + await proc.wait() + + _result = await proc.communicate() + result = _result[1].decode() + avrdude_res = _format_avrdude_response(result) + if avrdude_res[0]: + log.debug(result) + else: + log.error("Failed to update module firmware for {}: {}" + .format(port, avrdude_res[1])) + new_port = await _port_on_mode_switch(ports_before_update) + log.info("New port: {}".format(new_port)) + return new_port, avrdude_res + + +def _format_avrdude_response(raw_response: str) -> Tuple[bool, str]: + avrdude_log = '' + for line in raw_response.splitlines(): + if 'avrdude:' in line and line != raw_response.splitlines()[1]: + avrdude_log += line.lstrip('avrdude:') + '..' + if 'flash verified' in line: + return True, line.lstrip('avrdude: ') + return False, avrdude_log + + +async def _port_on_mode_switch(ports_before_switch): + ports_after_switch = await _discover_ports() + new_port = '' + if ports_after_switch and \ + len(ports_after_switch) >= len(ports_before_switch) and \ + not set(ports_before_switch) == set(ports_after_switch): + new_ports = list(filter( + lambda x: x not in ports_before_switch, + ports_after_switch)) + if len(new_ports) > 1: + raise OSError('Multiple new ports found on mode switch') + new_port = '/dev/modules/{}'.format(new_ports[0]) + return new_port + + +async def _port_poll(is_old_bootloader, ports_before_switch=None): + """ + Checks for the bootloader port + """ + new_port = '' + while not new_port: + if is_old_bootloader: + new_port = await _port_on_mode_switch(ports_before_switch) + else: + ports = await _discover_ports() + if ports: + discovered_ports = list(filter( + lambda x: x.endswith('bootloader'), ports)) + if len(discovered_ports) == 1: + new_port = '/dev/modules/{}'.format(discovered_ports[0]) + await asyncio.sleep(0.05) + return new_port + + +def _has_old_bootloader(model: str) -> bool: + return model in ('temp_deck_v1', 'temp_deck_v2') + + +async def _discover_ports(): + for attempt in range(2): + # Measure for race condition where port is being switched in + # between calls to isdir() and listdir() + try: + return os.listdir('/dev/modules') + except (FileNotFoundError, OSError): + pass + await asyncio.sleep(2) + raise Exception("No /dev/modules found. Try again") diff --git a/api/opentrons/hardware_control/simulator.py b/api/opentrons/hardware_control/simulator.py index 52af00d86a6a..78e810f211b2 100644 --- a/api/opentrons/hardware_control/simulator.py +++ b/api/opentrons/hardware_control/simulator.py @@ -1,4 +1,8 @@ -from typing import Dict +import asyncio +from typing import Dict, Optional, List, Tuple + +from opentrons import types +from . import modules class Simulator: @@ -6,11 +10,16 @@ class Simulator: hardware actions. It is suitable for use on a dev machine or on a robot with no smoothie connected. """ - - def __init__(self, attached_instruments, config, loop): + def __init__(self, + attached_instruments: Dict[types.Mount, Optional[str]], + attached_modules: List[str], + config, loop) -> None: self._config = config self._loop = loop self._attached_instruments = attached_instruments + self._attached_modules = [('mod' + str(idx), mod) + for idx, mod + in enumerate(attached_modules)] def move(self, target_position: Dict[str, float]): pass @@ -21,3 +30,16 @@ def home(self): def get_attached_instruments(self, mount): return self._attached_instruments[mount] + + def get_attached_modules(self) -> List[Tuple[str, str]]: + return self._attached_modules + + def build_module(self, port: str, model: str) -> modules.AbstractModule: + return modules.build(port, model, True) + + async def update_module( + self, module: modules.AbstractModule, + firmware_file: str, + loop: Optional[asyncio.AbstractEventLoop])\ + -> modules.AbstractModule: + return module diff --git a/api/opentrons/legacy_api/modules/__init__.py b/api/opentrons/legacy_api/modules/__init__.py index adebc1069267..2c3d3861c561 100644 --- a/api/opentrons/legacy_api/modules/__init__.py +++ b/api/opentrons/legacy_api/modules/__init__.py @@ -4,6 +4,7 @@ import asyncio from .magdeck import MagDeck from .tempdeck import TempDeck +from opentrons import HERE as package_root log = logging.getLogger(__name__) @@ -125,7 +126,7 @@ async def enter_bootloader(module): return new_port -async def update_firmware(module, firmware_file_path, config_file_path, loop): +async def update_firmware(module, firmware_file_path, loop): """ Run avrdude firmware upload command. Switch back to normal module port @@ -136,7 +137,8 @@ async def update_firmware(module, firmware_file_path, config_file_path, loop): # TODO: Make sure the module isn't in the middle of operation ports_before_update = await _discover_ports() - + config_file_path = os.path.join(package_root, + 'config', 'modules', 'avrdude.conf') proc = await asyncio.create_subprocess_exec( 'avrdude', '-C{}'.format(config_file_path), '-v', '-p{}'.format(PART_NO), diff --git a/api/opentrons/server/endpoints/update.py b/api/opentrons/server/endpoints/update.py index 97e45e16e2b1..aeb3876a9244 100644 --- a/api/opentrons/server/endpoints/update.py +++ b/api/opentrons/server/endpoints/update.py @@ -2,7 +2,6 @@ import asyncio import shutil import os -import opentrons import tempfile from aiohttp import web from opentrons import robot @@ -109,19 +108,17 @@ async def _update_module_firmware(module_serial, data, loop=None): fw_filename = data.filename content = data.file.read() log.info('Preparing to flash firmware image {}'.format(fw_filename)) - config_file_path = os.path.join(opentrons.HERE, - 'config', 'modules', 'avrdude.conf') + with tempfile.NamedTemporaryFile(suffix=fw_filename) as fp: fp.write(content) # returns a dict of 'message' & 'avrdudeResponse' - res = await _upload_to_module(module_serial, fp.name, - config_file_path, loop=loop) + res = await _upload_to_module(module_serial, fp.name, loop=loop) log.info('Firmware update complete') res['filename'] = fw_filename return res -async def _upload_to_module(serialnum, fw_filename, config_file_path, loop): +async def _upload_to_module(serialnum, fw_filename, loop): """ This method remains in the API currently because of its use of the robot singleton's copy of the api object & driver. This should move to the server @@ -143,13 +140,13 @@ async def _upload_to_module(serialnum, fw_filename, config_file_path, loop): if bootloader_port: module._port = bootloader_port # else assume old bootloader connection on existing module port - log.info("Uploading file to port:{} using config file {}".format( - module.port, config_file_path)) + log.info("Uploading file to port: {}".format( + module.port)) log.info("Flashing firmware. This will take a few seconds") try: res = await asyncio.wait_for( modules.update_firmware( - module, fw_filename, config_file_path, loop), + module, fw_filename, loop), UPDATE_TIMEOUT) except asyncio.TimeoutError: return {'message': 'AVRDUDE not responding'} diff --git a/api/tests/opentrons/hardware_control/modules/test_hc_magdeck.py b/api/tests/opentrons/hardware_control/modules/test_hc_magdeck.py new file mode 100644 index 000000000000..c7bb2d20b0f2 --- /dev/null +++ b/api/tests/opentrons/hardware_control/modules/test_hc_magdeck.py @@ -0,0 +1,26 @@ +from opentrons.hardware_control import modules + + +def test_sim_initialization(): + mag = modules.build('', 'magdeck', True) + assert isinstance(mag, modules.AbstractModule) + + +def test_sim_data(): + mag = modules.build('', 'magdeck', True) + assert mag.status == 'disengaged' + assert mag.device_info['serial'] == 'dummySerial' + assert mag.device_info['model'] == 'dummyModel' + assert mag.device_info['version'] == 'dummyVersion' + assert mag.live_data['status'] == mag.status + assert 'data' in mag.live_data + + +def test_sim_state_update(): + mag = modules.build('', 'magdeck', True) + mag.calibrate() + assert mag.status == 'disengaged' + mag.engage(2) + assert mag.status == 'engaged' + mag.disengage() + assert mag.status == 'disengaged' diff --git a/api/tests/opentrons/hardware_control/modules/test_hc_tempdeck.py b/api/tests/opentrons/hardware_control/modules/test_hc_tempdeck.py new file mode 100644 index 000000000000..6d0235925e38 --- /dev/null +++ b/api/tests/opentrons/hardware_control/modules/test_hc_tempdeck.py @@ -0,0 +1,50 @@ +import asyncio +from opentrons.hardware_control import modules +from opentrons.hardware_control.modules import tempdeck + + +def test_sim_initialization(): + temp = modules.build('', 'tempdeck', True) + assert isinstance(temp, modules.AbstractModule) + + +def test_sim_state(): + temp = modules.build('', 'tempdeck', True) + assert temp.temperature == 0 + assert temp.target == 0 + assert temp.status == 'idle' + assert temp.live_data['status'] == temp.status + assert temp.live_data['data']['currentTemp'] == temp.temperature + assert temp.live_data['data']['targetTemp'] == temp.target + status = temp.device_info + assert status['serial'] == 'dummySerial' + assert status['model'] == 'dummyModel' + assert status['version'] == 'dummyVersion' + + +async def test_sim_update(): + temp = modules.build('', 'tempdeck', True) + temp.set_temperature(10) + assert temp.temperature == 10 + assert temp.target == 10 + assert temp.status == 'holding at target' + await asyncio.wait_for(temp.wait_for_temp(), timeout=0.2) + temp.disengage() + assert temp.temperature == 0 + assert temp.target == 0 + assert temp.status == 'idle' + + +async def test_poller(monkeypatch): + temp = modules.tempdeck.TempDeck('', True) + hit = False + + def update_called(): + nonlocal hit + hit = True + + monkeypatch.setattr(temp._driver, 'update_temperature', update_called) + temp._connect() + assert temp._poller.is_alive() + await asyncio.sleep(tempdeck.TEMP_POLL_INTERVAL_SECS * 1.1) + assert hit diff --git a/api/tests/opentrons/hardware_control/test_modules.py b/api/tests/opentrons/hardware_control/test_modules.py new file mode 100644 index 000000000000..f812c8915849 --- /dev/null +++ b/api/tests/opentrons/hardware_control/test_modules.py @@ -0,0 +1,89 @@ +import pytest +import opentrons.hardware_control as hardware_control + + +async def test_get_modules_simulating(): + mods = ['tempdeck', 'magdeck'] + api = hardware_control.API.build_hardware_simulator(attached_modules=mods) + from_api = await api.discover_modules() + assert sorted([mod.name() for mod in from_api]) == sorted(mods) + + +async def test_module_caching(): + mod_names = ['tempdeck'] + api = hardware_control.API.build_hardware_simulator( + attached_modules=mod_names) + + # Check that we can add and remove modules and the caching keeps up + found_mods = await api.discover_modules() + assert found_mods[0].name() == 'tempdeck' + new_mods = await api.discover_modules() + assert new_mods[0] is found_mods[0] + api._backend._attached_modules.append(('mod2', 'magdeck')) + with_magdeck = await api.discover_modules() + assert len(with_magdeck) == 2 + assert with_magdeck[0] is found_mods[0] + api._backend._attached_modules = api._backend._attached_modules[1:] + only_magdeck = await api.discover_modules() + assert only_magdeck[0] is with_magdeck[1] + + # Check that two modules of the same kind on different ports are + # distinct + api._backend._attached_modules.append(('mod3', 'magdeck')) + two_magdecks = await api.discover_modules() + assert len(two_magdecks) == 2 + assert two_magdecks[0] is with_magdeck[1] + assert two_magdecks[1] is not two_magdecks[0] + + +async def test_module_update_logic(monkeypatch): + mod_names = ['tempdeck'] + api = hardware_control.API.build_hardware_simulator( + attached_modules=mod_names) + mods = await api.discover_modules() + old = mods[0] + + async def new_update_module(mod, ff, loop=None): + return hardware_control.modules.build('weird-port', mod.name(), True) + + monkeypatch.setattr(api._backend, 'update_module', new_update_module) + ok, msg = await api.update_module(mods[0], 'some_file') + + mods = await api.discover_modules() + assert len(mods) == 1 + + assert mods[0] is not old + + +@pytest.mark.skipIf(not hardware_control.Controller, + reason='hardware controller not available') +async def test_module_update_integration(monkeypatch, loop, running_on_pi): + api = hardware_control.API.build_hardware_controller(loop=loop) + + def mock_get_modules(): + return [('port1', 'tempdeck')] + + monkeypatch.setattr(api._backend, 'get_attached_modules', mock_get_modules) + + def mock_build_module(port, model): + return hardware_control.modules.build(port, model, True) + + monkeypatch.setattr(api._backend, 'build_module', mock_build_module) + + async def mock_discover_ports(): + return ['port1'] + + monkeypatch.setattr(hardware_control.modules.update, + '_discover_ports', mock_discover_ports) + + async def mock_update(port, fname, loop): + return (port, (True, 'it all worked')) + + monkeypatch.setattr(hardware_control.modules.update, + 'update_firmware', mock_update) + + modules = await api.discover_modules() + ok, msg = await api.update_module(modules[0], 'some-fake-file', loop) + assert ok + new_modules = await api.discover_modules() + assert new_modules[0] is not modules[0] diff --git a/api/tests/opentrons/hardware_control/test_moves.py b/api/tests/opentrons/hardware_control/test_moves.py index 10925b6767bf..474e6daf1e71 100644 --- a/api/tests/opentrons/hardware_control/test_moves.py +++ b/api/tests/opentrons/hardware_control/test_moves.py @@ -7,17 +7,13 @@ def hardware_api(monkeypatch, loop): def mock_move(position): pass - attached_pipettes = {types.Mount.LEFT: None, types.Mount.RIGHT: None} - hw_api = hc.API.build_hardware_simulator( - attached_instruments=attached_pipettes, loop=loop) + hw_api = hc.API.build_hardware_simulator(loop=loop) monkeypatch.setattr(hw_api._backend, 'move', mock_move) return hw_api async def test_controller_home(loop): - attached_pipettes = {types.Mount.LEFT: None, types.Mount.RIGHT: None} - c = hc.API.build_hardware_simulator( - attached_instruments=attached_pipettes, loop=loop) + c = hc.API.build_hardware_simulator(loop=loop) await c.home() assert c._current_position == {'X': 418, 'Y': 353, 'Z': 218, 'A': 218, 'B': 19, 'C': 19} diff --git a/api/tests/opentrons/server/test_update_endpoints.py b/api/tests/opentrons/server/test_update_endpoints.py index a4ecbbe6b339..fc7da3e2f39a 100644 --- a/api/tests/opentrons/server/test_update_endpoints.py +++ b/api/tests/opentrons/server/test_update_endpoints.py @@ -132,7 +132,7 @@ async def mock_enter_bootloader(module): 'filename': fw_filename} async def mock_successful_upload_to_module( - module, fw_file, config_file, loop): + module, fw_file, loop): return res_msg expected_res = res_msg @@ -175,7 +175,7 @@ async def mock_enter_bootloader(module): 'filename': fw_filename} async def mock_failed_upload_to_module1( - serialnum, fw_file, config_file, loop): + serialnum, fw_file, loop): return res_msg1 expected_res1 = res_msg1 @@ -196,7 +196,7 @@ async def mock_failed_upload_to_module1( 'filename': fw_filename} async def mock_failed_upload_to_module2( - serialnum, fw_file, config_file, loop): + serialnum, fw_file, loop): return res_msg2 expected_res2 = res_msg2 @@ -216,7 +216,7 @@ async def mock_failed_upload_to_module2( 'filename': fw_filename} async def mock_failed_upload_to_module3( - serialnum, fw_file, config_file, loop): + serialnum, fw_file, loop): await asyncio.sleep(2) monkeypatch.setattr(modules,