Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

API: add module support to hardware control #2423

Merged
merged 2 commits into from
Oct 9, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions api/opentrons/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,17 @@
if ff.use_protocol_api_v2():
import protocol_api
from protocol_api.back_compat\
import robot, reset as bcreset, instruments, containers, labware
import robot, reset as bcreset, instruments, containers, labware,\
modules

def reset():
ctx = protocol_api.ProtocolContext()
bcreset(ctx)

else:
from .legacy_api.api import robot, reset, instruments, containers, labware
from .legacy_api.api\
import robot, reset, instruments, containers, labware, modules


__all__ = ['containers', 'instruments', 'labware', 'robot', 'reset',
'__version__']
'__version__', 'modules']
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()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Won't this only return the output of modules.discover()?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the code's a little annoyingly multilayered here. The module object caching code (and therefore module object creation code) wants to live in hardware_control/__init__.py:API, and the actual work of discovering modules lives in modules/__init__.py and shouldn't be used if we're simulating, so the backend really only either a) returns the preconfigured list of modules (as simulator.py does) or just returns the output of modules.discover() (as this does).


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