Skip to content

Commit

Permalink
feat(api): Add modules api to hardware_control
Browse files Browse the repository at this point in the history
Closes #2237
  • Loading branch information
sfoster1 committed Oct 5, 2018
1 parent f956902 commit c13660c
Show file tree
Hide file tree
Showing 16 changed files with 900 additions and 29 deletions.
3 changes: 2 additions & 1 deletion api/opentrons/drivers/temp_deck/driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
54 changes: 51 additions & 3 deletions api/opentrons/hardware_control/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,15 @@
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:
from .controller import Controller
except ModuleNotFoundError:
# implies windows
Controller = None # type: ignore
from . import modules


mod_log = logging.getLogger(__name__)
Expand Down Expand Up @@ -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(
Expand All @@ -108,15 +110,23 @@ 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.
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
Expand Down Expand Up @@ -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'
23 changes: 22 additions & 1 deletion api/opentrons/hardware_control/controller.py
Original file line number Diff line number Diff line change
@@ -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()

Expand Down Expand Up @@ -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(
Expand All @@ -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)
88 changes: 88 additions & 0 deletions api/opentrons/hardware_control/modules/__init__.py
Original file line number Diff line number Diff line change
@@ -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)
137 changes: 137 additions & 0 deletions api/opentrons/hardware_control/modules/magdeck.py
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit c13660c

Please sign in to comment.